From f8a397a14c2fbfe2ceaadbd3acb43ebffa7c8f64 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 27 Jul 2026 21:24:17 +0400 Subject: [PATCH] fix(harness): add automatic TaskCompleted producer (B3, partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /v1/harness/complete lets a Claude Code Stop hook report task completion instead of relying on a human hitting the manual endpoint. The hook only fires on an explicit .orchestra-report.md marker (not every turn boundary); the server builds the receipt itself from the real transcript via herdr.ClaudeUsage rather than trusting a self-reported number. Codex/opencode producers and the turn-decision endpoint are still unbuilt — see AUDIT.md/progress.md for scope. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT --- AUDIT.md | 31 +++++++++++++++++ cmd/orchestra/main.go | 62 ++++++++++++++++++++++++++++++++++ deploy/hooks/orchestra-stop.sh | 48 ++++++++++++++++++++++++++ progress.md | 29 ++++++++++++++-- 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100755 deploy/hooks/orchestra-stop.sh diff --git a/AUDIT.md b/AUDIT.md index e513994..d4758a7 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -527,6 +527,37 @@ adapter.go:** `go build ./...`, `go vet ./...`, `go test ./...` all pass after these changes. +## B3 — partial fix, 2026-07-27 + +Added `POST /v1/harness/complete` (`cmd/orchestra/main.go`) — the first +automatic `TaskCompleted` producer. Design: a Claude Code Stop hook +(`deploy/hooks/orchestra-stop.sh`) runs on every turn boundary but only POSTs +when the agent has written `.orchestra-report.md` at the worktree root — +that file is the explicit "I'm done" signal, since Stop fires on every pause +and treating every stop as completion would be wrong (this is exactly the +distinction Phase 2 items 1–2, the `continue`/`prepare_handoff`/`rotate_now`/ +`refuse` turn-decision endpoint, are meant to own — that endpoint is still +unbuilt, so there is currently no plane-side signal telling the agent when to +rotate vs. finish; the marker-file convention is a stopgap that only covers +the completion half). + +The handler builds the `receipt` server-side from `herdr.ClaudeUsage` against +the transcript path the hook supplies (same local-filesystem assumption as +`CLIAdapter.Occupancy` — doesn't hold for a session hosted on a herdr that +isn't local to the machine running orchestra, i.e. the federation-fork +caveat applies here too) rather than trusting a self-reported number, and +uploads the report body via `Store.PutArtifact` for `report_ref`. Event is +appended with `Surface: string(authz.System)` hardcoded in Go — not read from +a request header — consistent with B8 (system must never be +header-controlled); gated instead by an optional `ORCHESTRA_HARNESS_TOKEN` +bearer check. + +**Not done:** Codex/opencode completion producers (only Claude wired), the +turn-decision endpoint itself, and no test — `cmd/orchestra/main.go` has zero +handler test coverage of any kind (everything is inline in `main()`), so this +follows the existing gap rather than introducing an isolated test harness for +one handler. + **Still open from B5** (not attempted this pass — larger, needs design, not just a method-name swap): - `agent.prompt` inline `wait` on `CLIAdapter.Lease` (still `wait=0`, per B5's diff --git a/cmd/orchestra/main.go b/cmd/orchestra/main.go index bb42c63..0d6e001 100644 --- a/cmd/orchestra/main.go +++ b/cmd/orchestra/main.go @@ -242,6 +242,68 @@ func main() { w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"ref": ref}) }) + // /v1/harness/complete is the automatic TaskCompleted producer (AUDIT.md + // B3): a harness-side hook posts here when the agent has declared the + // task done (see deploy/hooks/orchestra-stop.sh), not on every turn + // boundary. It reads the transcript locally to build an honest receipt — + // same session-file assumption as CLIAdapter.Occupancy — rather than + // trusting a self-reported number. + harnessToken := os.Getenv("ORCHESTRA_HARNESS_TOKEN") + mux.HandleFunc("/v1/harness/complete", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if harnessToken != "" && r.Header.Get("Authorization") != "Bearer "+harnessToken { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + var p struct { + TaskID string `json:"task_id"` + TranscriptPath string `json:"transcript_path"` + Report string `json:"report"` + } + if json.NewDecoder(r.Body).Decode(&p) != nil || p.TaskID == "" || p.Report == "" || p.TranscriptPath == "" { + http.Error(w, "task_id, transcript_path, and report are required", http.StatusBadRequest) + return + } + t, ok := s.Task(p.TaskID) + if !ok { + http.Error(w, "task not found", http.StatusNotFound) + return + } + usage, err := herdr.ClaudeUsage(p.TranscriptPath) + if err != nil { + http.Error(w, "reading transcript: "+err.Error(), http.StatusBadRequest) + return + } + ref, err := s.PutArtifact([]byte(p.Report)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + payload, _ := json.Marshal(map[string]any{ + "report_ref": ref, + "receipt": map[string]any{ + "input_tokens": usage.Input, + "cache_read_tokens": usage.CacheRead, + "cache_write_tokens": usage.CacheWrite, + "output_tokens": usage.Output, + "numerator": usage.Numerator(), + }, + }) + e := domain.Event{ID: id(), Type: "TaskCompleted", TaskID: p.TaskID, Version: t.Version + 1, Payload: payload, Surface: string(authz.System)} + if err := s.Append(e); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + if rt != nil { + if _, routeErr := rt.HandleEvent(e); routeErr != nil { + log.Printf("route task: %v", routeErr) + } + } + json.NewEncoder(w).Encode(e) + }) mux.HandleFunc("/v1/brief", func(w http.ResponseWriter, r *http.Request) { to := time.Now().UTC() from := to.Add(-12 * time.Hour) diff --git a/deploy/hooks/orchestra-stop.sh b/deploy/hooks/orchestra-stop.sh new file mode 100755 index 0000000..fe4cee0 --- /dev/null +++ b/deploy/hooks/orchestra-stop.sh @@ -0,0 +1,48 @@ +#!/bin/sh +# Claude Code Stop hook — fires on every turn boundary, not just completion. +# Convention: the agent signals "this task is done" by writing a report file +# named .orchestra-report.md at the worktree root before stopping. If that +# marker is absent, this is an ordinary turn boundary and the hook is a +# no-op — do not treat every stop as completion (AUDIT.md B3). +# +# Requires: ORCHESTRA_TASK_ID and ORCHESTRA_URL set in the pane's env. +# Optional: ORCHESTRA_HARNESS_TOKEN if the server requires one. +# +# Reads the Stop hook's JSON payload from stdin (has "transcript_path" and +# "cwd"); needs jq. + +set -eu + +payload="$(cat)" +transcript_path="$(printf '%s' "$payload" | jq -r '.transcript_path // empty')" +cwd="$(printf '%s' "$payload" | jq -r '.cwd // empty')" + +[ -z "$transcript_path" ] && exit 0 +[ -z "${ORCHESTRA_TASK_ID:-}" ] && exit 0 +[ -z "${ORCHESTRA_URL:-}" ] && exit 0 + +report_file="${cwd:-.}/.orchestra-report.md" +[ -f "$report_file" ] || exit 0 + +report="$(cat "$report_file")" + +auth_header="" +if [ -n "${ORCHESTRA_HARNESS_TOKEN:-}" ]; then + auth_header="Authorization: Bearer ${ORCHESTRA_HARNESS_TOKEN}" +fi + +body="$(jq -n \ + --arg task_id "$ORCHESTRA_TASK_ID" \ + --arg transcript_path "$transcript_path" \ + --arg report "$report" \ + '{task_id: $task_id, transcript_path: $transcript_path, report: $report}')" + +if curl -fsS -X POST "${ORCHESTRA_URL%/}/v1/harness/complete" \ + -H "Content-Type: application/json" \ + ${auth_header:+-H "$auth_header"} \ + -d "$body" >/dev/null 2>&1; then + rm -f "$report_file" +else + echo "orchestra-stop: failed to report completion" >&2 + exit 2 +fi diff --git a/progress.md b/progress.md index faa31ae..b237fa3 100644 --- a/progress.md +++ b/progress.md @@ -52,9 +52,32 @@ Fixed so far: polling would error out of its scan loop on the first already-ingested issue in every batch. -Not yet started: B3 (no `TaskCompleted` producer / no Stop hook), B6 (Layer 3 -wiring), B7 (quota projection has no producer), S2–S4, S7–S11. See -`AUDIT.md` for the full plan. +- **B3 (partial)** — added `POST /v1/harness/complete`, the first automatic + `TaskCompleted` producer (previously only a human calling + `/v1/tasks/{id}/complete` could ever complete a task). A Claude Code Stop + hook (`deploy/hooks/orchestra-stop.sh`) fires on every turn boundary but + only reports completion if the agent has written a `.orchestra-report.md` + marker at the worktree root first — an ordinary turn boundary is a no-op, + so this doesn't fire completion prematurely. The server reads the + transcript locally via `herdr.ClaudeUsage` to build the `receipt` itself + (input/cache/output token counts) rather than trusting a self-reported + number, and uploads the report body to CAS for `report_ref`. Guarded by an + optional `ORCHESTRA_HARNESS_TOKEN` bearer check; the event is appended with + `Surface: system` set directly in Go (not derived from a request header — + consistent with the B8 fix that system must never be header-controlled). + **Not done:** Codex/opencode equivalents (Claude-only for now — Codex would + need `CodexActiveUsage`, opencode `OpenCodeUsage`/`OpenCodeStatus`, wired + the same way), and the turn-boundary decision endpoint + (`continue`/`prepare_handoff`/`rotate_now`/`refuse`) from Phase 2 items 1–2 + is still unbuilt — only the completion half of Phase 2 landed. No test + added for the new HTTP handler; `cmd/orchestra/main.go` has zero test + coverage for any handler (pre-existing gap, everything lives inline in + `main()`) so this follows the existing (untested) pattern rather than + introducing a one-off test harness. + +Not yet started: B6 (Layer 3 wiring), B7 (quota projection has no producer), +Codex/opencode completion producers, the turn-decision endpoint, S2–S4, +S7–S11. See `AUDIT.md` for the full plan. **Phase 0 done (2026-07-27):** this box has live TCP reachability to the real herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC