Give the reviewed change a path to the human

The completion tail ended at TaskCompleted with no pull request. Nothing in
the running system ever called the review or submission endpoints: the whole
event log holds zero ReviewRecorded and zero TaskSubmitted, so the merge
reflection, the publisher and the human trust boundary had no entry point.

Four links, in the order the tail needs them:

- finalize commits first and runs the quality gate against the committed
  tree, so the gate result is bound to the commit being submitted.
  CheckSubmission requires gate sha, review sha and head sha to be one
  commit, which a gate run on the pre-commit tree can never satisfy.
- The worker seals the reviewer's findings and submits, through a new
  /v1/federation/workers/<id>/submit. A blocking review returns the task to
  implementation instead; a project with no forge still completes directly.
- The reviewing session is told where findings go. The brief asked for
  findings and named no file, and it described a diff nobody supplied.
- GiteaPublisher.Push asks the forge what the branch holds before reaching
  for a local checkout. A worker-owned worktree is on another machine and
  has already pushed the commit; the coordinator has no such directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-28 10:28:50 +04:00
parent d9a5a61965
commit e8d04d719d
6 changed files with 259 additions and 26 deletions
+84 -21
View File
@@ -146,8 +146,17 @@ type completionEvidence struct {
Remote string `json:"remote"`
QualityGate string `json:"quality_gate,omitempty"`
GateExit int `json:"gate_exit"`
GateOutput string `json:"gate_output,omitempty"`
CompletedAt time.Time `json:"completed_at"`
}
// tail keeps the end of a gate log, which is where the failure is.
func tail(s string, max int) string {
if len(s) <= max {
return s
}
return s[len(s)-max:]
}
type workerState struct {
Cursor uint64 `json:"cursor"`
Sessions map[string]herdr.Session `json:"sessions"`
@@ -568,11 +577,27 @@ func (w *worker) releaseReady(ctx context.Context) {
log.Printf("upload completion %s: %v", id, err)
continue
}
if err = w.api.Complete(ctx, id, ref, evidence.ResultSHA, evidence.Branch, evidence.Remote, w.leases[id].Epoch, w.leases[id].Version, w.usageReceipt(s, w.leases[id]), w.sessionEvidence(ctx, id, s)); err != nil {
w.recordError(fmt.Errorf("complete %s: %w", id, err))
log.Printf("complete %s: %v", id, err)
outcome, err := w.submit(ctx, id, s, evidence)
if err != nil {
w.recordError(fmt.Errorf("submit %s: %w", id, err))
log.Printf("submit %s: %v", id, err)
continue
}
if outcome == federation.SubmitChangesRequested {
// The sealed review sent the work back. The task is in
// implement again, so the done marker is stale and the phase
// change rotates this session on the next tick.
_ = os.Remove(filepath.Join(s.Worktree, ".orchestra", "done"))
log.Printf("review returned %s to implementation", id)
continue
}
if outcome == federation.SubmitNoPublisher {
if err = w.api.Complete(ctx, id, ref, evidence.ResultSHA, evidence.Branch, evidence.Remote, w.leases[id].Epoch, w.leases[id].Version, w.usageReceipt(s, w.leases[id]), w.sessionEvidence(ctx, id, s)); err != nil {
w.recordError(fmt.Errorf("complete %s: %w", id, err))
log.Printf("complete %s: %v", id, err)
continue
}
}
// Completion is durable before closing the exact pane. If close
// fails, retain the session mapping for a later explicit cleanup.
a := herdr.CLIAdapter{Backend: w.executionBackend(), Harness: w.harness}
@@ -946,19 +971,6 @@ func (w *worker) finalize(ctx context.Context, id string, s herdr.Session) (comp
return completionEvidence{}, fmt.Errorf("base sha: %s: %w", base, err)
}
e := completionEvidence{TaskID: id, Project: t.Project, Worker: w.harnessID, Harness: w.harness, PaneID: s.PaneID, BaseSHA: strings.TrimSpace(string(base)), Remote: p.Remote, QualityGate: t.QualityGate, CompletedAt: time.Now().UTC()}
gateCommand := t.QualityGate
if gateCommand == "" {
gateCommand = p.QualityGate
}
e.QualityGate = gateCommand
if gateCommand != "" {
gate := exec.CommandContext(ctx, "sh", "-c", gateCommand)
gate.Dir = s.Worktree
if out, err := gate.CombinedOutput(); err != nil {
e.GateExit = 1
return completionEvidence{}, fmt.Errorf("quality gate %q: %s: %w", gateCommand, out, err)
}
}
if _, err := git(ctx, s.Worktree, "diff", "--quiet", "--", "TASK.md"); err != nil {
return completionEvidence{}, errors.New("TASK.md was modified")
}
@@ -970,16 +982,34 @@ func (w *worker) finalize(ctx context.Context, id string, s herdr.Session) (comp
return completionEvidence{}, fmt.Errorf("commit result: %s: %w", out, err)
}
}
branch, err := git(ctx, s.Worktree, "branch", "--show-current")
if err != nil || strings.TrimSpace(string(branch)) == "" {
return completionEvidence{}, fmt.Errorf("result branch: %s: %w", branch, err)
}
e.Branch = strings.TrimSpace(string(branch))
sha, err := git(ctx, s.Worktree, "rev-parse", "HEAD")
if err != nil {
return completionEvidence{}, fmt.Errorf("result sha: %s: %w", sha, err)
}
e.ResultSHA = strings.TrimSpace(string(sha))
// The gate runs after the result commit, against a clean tree that is
// byte-for-byte the commit being submitted. Running it first bound the
// evidence to the pre-commit HEAD, which CheckSubmission rejects because
// gate sha, review sha and head sha must be one commit.
gateCommand := t.QualityGate
if gateCommand == "" {
gateCommand = p.QualityGate
}
e.QualityGate = gateCommand
if gateCommand != "" {
gate := exec.CommandContext(ctx, "sh", "-c", gateCommand)
gate.Dir = s.Worktree
out, gateErr := gate.CombinedOutput()
e.GateOutput = tail(string(out), review.MaxGateOutputBytes)
if gateErr != nil {
e.GateExit = 1
return completionEvidence{}, fmt.Errorf("quality gate %q: %s: %w", gateCommand, out, gateErr)
}
}
// The submission branch is derived, not the worktree's local name: the
// coordinator computes "orchestra/<task>" independently, and a pull
// request can only be opened for a branch both halves name the same way.
e.Branch = "orchestra/" + id
if out, err := git(ctx, s.Worktree, "push", p.Remote, "HEAD:refs/heads/"+e.Branch); err != nil {
return completionEvidence{}, fmt.Errorf("push result: %s: %w", out, err)
}
@@ -990,6 +1020,39 @@ func (w *worker) finalize(ctx context.Context, id string, s herdr.Session) (comp
return e, nil
}
// reviewFile is where the reviewing session leaves its findings. The reviewer
// supplies findings and nothing else: the commit they are bound to is the
// result commit the worker just made, which the agent cannot know and must not
// assert.
const reviewFile = "review.json"
// submit seals the review and hands the result to the human through a pull
// request. It is the only path from a reviewed change to TaskSubmitted; the
// direct completion call remains for a project with no forge configured.
func (w *worker) submit(ctx context.Context, id string, s herdr.Session, e completionEvidence) (string, error) {
var result review.Result
b, err := os.ReadFile(filepath.Join(s.Worktree, ".orchestra", reviewFile))
if err != nil {
// An absent review file is not a pass. Submission needs a sealed
// review, and inventing an empty one would launder "the reviewer wrote
// nothing" into "the reviewer found nothing".
return "", fmt.Errorf("review phase sealed no .orchestra/%s: %w", reviewFile, err)
}
if err := json.Unmarshal(b, &result); err != nil {
return "", fmt.Errorf(".orchestra/%s: %w", reviewFile, err)
}
result.ResultSHA = e.ResultSHA
if err := result.Validate(); err != nil {
return "", fmt.Errorf(".orchestra/%s: %w", reviewFile, err)
}
l := w.leases[id]
// The gate result is bound to the commit the gate ran against, which
// finalize guarantees is the commit being submitted. A project with no
// quality gate still produces a bound result: Passed() needs the sha.
gate := domain.GateResult{Command: e.QualityGate, ExitCode: e.GateExit, SHA: e.ResultSHA, Output: e.GateOutput}
return w.api.Submit(ctx, id, l.Epoch, l.Version, e.ResultSHA, e.Remote, result, gate)
}
// stageExclude keeps Orchestra's own control files out of the result commit.
// It names the directory, not the marker inside it: .orchestra carries a
// .gitignore of "*" (herdr/adapter.go), and naming an ignored file in a
+50 -2
View File
@@ -1378,7 +1378,7 @@ func main() {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/v1/federation/workers/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/renew") && !strings.HasSuffix(r.URL.Path, "/start") && !strings.HasSuffix(r.URL.Path, "/nack") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/pickup") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/captures")) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/renew") && !strings.HasSuffix(r.URL.Path, "/start") && !strings.HasSuffix(r.URL.Path, "/nack") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/pickup") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/submit") && !strings.HasSuffix(r.URL.Path, "/captures")) {
http.Error(w, "not found", 404)
return
}
@@ -1443,6 +1443,8 @@ func main() {
FailureClass string `json:"failure_class"`
LastError string `json:"last_error"`
SessionEvidence domain.SessionEvidence `json:"session_evidence"`
Review review.Result `json:"review"`
Gate domain.GateResult `json:"gate"`
}
if json.NewDecoder(r.Body).Decode(&b) != nil || b.TaskID == "" {
http.Error(w, "invalid lease body", 400)
@@ -1465,10 +1467,56 @@ func main() {
http.Error(w, "lease not owned", 409)
return
}
if strings.HasSuffix(r.URL.Path, "/complete") && b.ExpectedVersion != t.Version {
if (strings.HasSuffix(r.URL.Path, "/complete") || strings.HasSuffix(r.URL.Path, "/submit")) && b.ExpectedVersion != t.Version {
http.Error(w, "lease version conflict", http.StatusConflict)
return
}
if strings.HasSuffix(r.URL.Path, "/submit") {
// Seal the review, then submit the commit it examined. Both events
// are Orchestra's; the worker supplies verified evidence and the
// coordinator decides what it means.
project, ok := rr.Project(t.Project)
if !ok {
http.Error(w, "unknown project "+t.Project, 409)
return
}
if len(b.ResultSHA) != 40 {
http.Error(w, "verified result_sha required", 400)
return
}
if !t.Submitted(b.ResultSHA) && (t.Review == nil || t.Review.ResultSHA != b.ResultSHA) {
b.Review.ResultSHA = b.ResultSHA
if _, err := operations.RecordReview(s, project, b.TaskID, b.Review); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if !b.Review.Accepted() {
// The review sent the work back to implementation. That is
// an outcome, not a failure, and there is nothing to submit.
json.NewEncoder(w).Encode(map[string]any{"status": federation.SubmitChangesRequested, "blocking": len(b.Review.Blocking())})
return
}
}
if submissionPublisher == nil {
json.NewEncoder(w).Encode(map[string]string{"status": federation.SubmitNoPublisher})
return
}
plan, err := operations.PrepareSubmission(s, project, b.TaskID, b.ResultSHA, b.Gate, operations.Notes{})
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if b.Remote != "" {
plan.Remote = b.Remote
}
e, err := operations.ExecuteSubmission(r.Context(), s, plan, submissionPublisher(plan), nil)
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
json.NewEncoder(w).Encode(map[string]any{"status": federation.SubmitSubmitted, "event": e})
return
}
if strings.HasSuffix(r.URL.Path, "/start") {
// A lost response after append is an idempotent start ACK, not a
// reason to strand the running pane behind a stale version.