7f12c7fc37
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
5.8 KiB
Go
175 lines
5.8 KiB
Go
package domain
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// EventTaskSubmitted records that a reviewed change reached the human. It is
|
|
// deliberately not a completion: submission means the work is in the human's
|
|
// hands, and completion means the change shipped.
|
|
const EventTaskSubmitted = "TaskSubmitted"
|
|
|
|
// EventTaskChangesRequested records that the human sent a submitted change
|
|
// back. The submission it names is not removed: sha A was reviewed, submitted,
|
|
// and rejected, and that history is what explains sha B.
|
|
const EventTaskChangesRequested = "TaskChangesRequested"
|
|
|
|
// CompletionReceipt is the evidence that a submission shipped. Merge strategy
|
|
// varies, so a squash or merge commit means MergeSHA rarely equals
|
|
// SubmittedSHA. What establishes completion is that the bound pull request
|
|
// merged while carrying the submitted commit, not sha equality.
|
|
type CompletionReceipt struct {
|
|
SubmissionRef string `json:"submission_ref"`
|
|
PR ExternalRef `json:"pr"`
|
|
SubmittedSHA string `json:"submitted_sha"`
|
|
MergeSHA string `json:"merge_sha,omitempty"`
|
|
MergedAt time.Time `json:"merged_at"`
|
|
}
|
|
|
|
// GateResult is one quality-gate run, bound to the commit it ran against. A
|
|
// gate result with no commit is a claim, not evidence.
|
|
type GateResult struct {
|
|
Command string `json:"command"`
|
|
ExitCode int `json:"exit_code"`
|
|
SHA string `json:"sha"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
func (g GateResult) Passed() bool { return g.ExitCode == 0 && len(g.SHA) == 40 }
|
|
|
|
// ExternalRef identifies a pull request in the forge that holds it.
|
|
type ExternalRef struct {
|
|
Provider string `json:"provider"`
|
|
ID string `json:"id"`
|
|
URL string `json:"url,omitempty"`
|
|
}
|
|
|
|
// SubmissionRef is the durable record of what was submitted. Every field binds
|
|
// the submission to one commit, so a later change cannot inherit it.
|
|
type SubmissionRef struct {
|
|
ResultSHA string `json:"result_sha"`
|
|
RemoteRef string `json:"remote_ref"`
|
|
PR ExternalRef `json:"pr"`
|
|
GateRef string `json:"gate_ref,omitempty"`
|
|
ReviewRef string `json:"review_ref,omitempty"`
|
|
PacketRef string `json:"packet_ref,omitempty"`
|
|
}
|
|
|
|
// SubmissionCheck is why a task may or may not be submitted. Reasons are
|
|
// listed rather than summarised: "not eligible" alone sends an operator
|
|
// reading code.
|
|
type SubmissionCheck struct {
|
|
Eligible bool `json:"eligible"`
|
|
Reasons []string `json:"reasons,omitempty"`
|
|
}
|
|
|
|
// CheckSubmission is the whole eligibility rule, as one pure function of the
|
|
// task, the current commit, and the gate run.
|
|
//
|
|
// The invariant that matters most: gate sha, review sha, and head sha must be
|
|
// the same commit. Anything changing after review makes submission ineligible
|
|
// immediately, with no state to clear and no flag to go stale.
|
|
func CheckSubmission(task Task, headSHA string, gate GateResult) SubmissionCheck {
|
|
var reasons []string
|
|
add := func(format string, args ...any) { reasons = append(reasons, fmt.Sprintf(format, args...)) }
|
|
|
|
phase := task.WorkPhase
|
|
if phase == "" {
|
|
phase = WorkPhaseFrame
|
|
}
|
|
if phase != WorkPhaseReview {
|
|
add("work phase is %s, not review", phase)
|
|
}
|
|
if task.State == StateBlocked || task.State == StateNeedsAttention {
|
|
add("task is %s (%s)", task.State, task.BlockReason)
|
|
}
|
|
if task.State == StateCompleted || task.State == StateFailed {
|
|
add("task is already %s", task.State)
|
|
}
|
|
if task.DecisionRequest != nil {
|
|
add("a human decision is still outstanding")
|
|
}
|
|
if len(headSHA) != 40 {
|
|
add("head commit is not anchored")
|
|
}
|
|
if !gate.Passed() {
|
|
add("quality gate %q exited %d", gate.Command, gate.ExitCode)
|
|
} else if gate.SHA != headSHA {
|
|
add("quality gate ran against %s, not the current head", short(gate.SHA))
|
|
}
|
|
switch {
|
|
case task.Review == nil:
|
|
add("no independent review has been recorded")
|
|
case task.Review.ResultSHA != headSHA:
|
|
add("the review is for %s, not the current head", short(task.Review.ResultSHA))
|
|
case task.Review.Blocking > 0:
|
|
add("%d unresolved blocker or important review findings", task.Review.Blocking)
|
|
}
|
|
return SubmissionCheck{Eligible: len(reasons) == 0, Reasons: reasons}
|
|
}
|
|
|
|
// RequirePhaseArtifacts reports the project-policy half of eligibility: a
|
|
// project whose path includes research or plan must have sealed them.
|
|
func (t Task) RequirePhaseArtifacts(path []WorkPhase) []string {
|
|
var missing []string
|
|
for _, phase := range path {
|
|
switch phase {
|
|
case WorkPhaseResearch:
|
|
if t.ResearchRef == "" {
|
|
missing = append(missing, "the project's path includes research but none was sealed")
|
|
}
|
|
case WorkPhasePlan:
|
|
if t.PlanRef == "" {
|
|
missing = append(missing, "the project's path includes plan but none was sealed")
|
|
}
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// Submitted reports whether this task already has a submission for exactly
|
|
// this commit, which is what makes a repeated submission idempotent.
|
|
func (t Task) Submitted(headSHA string) bool {
|
|
return t.Submission != nil && t.Submission.ResultSHA == headSHA
|
|
}
|
|
|
|
func ValidateTaskSubmitted(p map[string]any) error {
|
|
if v, ok := p["result_sha"].(string); !ok || len(v) != 40 {
|
|
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
|
}
|
|
if v, ok := p["remote_ref"].(string); !ok || strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: remote_ref required", ErrInvalid)
|
|
}
|
|
pr, ok := p["pr"].(map[string]any)
|
|
if !ok {
|
|
return fmt.Errorf("%w: pr required", ErrInvalid)
|
|
}
|
|
for _, k := range []string{"provider", "id"} {
|
|
if v, ok := pr[k].(string); !ok || strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: pr.%s required", ErrInvalid, k)
|
|
}
|
|
}
|
|
for _, k := range []string{"gate_ref", "review_ref", "packet_ref"} {
|
|
if v, ok := p[k]; ok {
|
|
if s, _ := v.(string); s != "" {
|
|
if err := requiredHash(map[string]any{k: s}, k); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func short(sha string) string {
|
|
if len(sha) > 12 {
|
|
return sha[:12]
|
|
}
|
|
if sha == "" {
|
|
return "an unknown commit"
|
|
}
|
|
return sha
|
|
}
|