fix(harness): add automatic TaskCompleted producer (B3, partial)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W1rkJ2hBMybnJctPbcy4tT
This commit is contained in:
@@ -527,6 +527,37 @@ adapter.go:**
|
|||||||
`go build ./...`, `go vet ./...`, `go test ./...` all pass after these
|
`go build ./...`, `go vet ./...`, `go test ./...` all pass after these
|
||||||
changes.
|
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
|
**Still open from B5** (not attempted this pass — larger, needs design, not
|
||||||
just a method-name swap):
|
just a method-name swap):
|
||||||
- `agent.prompt` inline `wait` on `CLIAdapter.Lease` (still `wait=0`, per B5's
|
- `agent.prompt` inline `wait` on `CLIAdapter.Lease` (still `wait=0`, per B5's
|
||||||
|
|||||||
@@ -242,6 +242,68 @@ func main() {
|
|||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
json.NewEncoder(w).Encode(map[string]string{"ref": ref})
|
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) {
|
mux.HandleFunc("/v1/brief", func(w http.ResponseWriter, r *http.Request) {
|
||||||
to := time.Now().UTC()
|
to := time.Now().UTC()
|
||||||
from := to.Add(-12 * time.Hour)
|
from := to.Add(-12 * time.Hour)
|
||||||
|
|||||||
Executable
+48
@@ -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
|
||||||
+26
-3
@@ -52,9 +52,32 @@ Fixed so far:
|
|||||||
polling would error out of its scan loop on the first already-ingested
|
polling would error out of its scan loop on the first already-ingested
|
||||||
issue in every batch.
|
issue in every batch.
|
||||||
|
|
||||||
Not yet started: B3 (no `TaskCompleted` producer / no Stop hook), B6 (Layer 3
|
- **B3 (partial)** — added `POST /v1/harness/complete`, the first automatic
|
||||||
wiring), B7 (quota projection has no producer), S2–S4, S7–S11. See
|
`TaskCompleted` producer (previously only a human calling
|
||||||
`AUDIT.md` for the full plan.
|
`/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
|
**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
|
herdr instance at `192.168.1.105:9245` — verified by hand (raw JSON-RPC
|
||||||
|
|||||||
Reference in New Issue
Block a user