75 KiB
Orchestra — spec conformance audit & remediation status
Audited 2026-07-27 against orchestra-spec (1).md at commit 325c684.
Method: read every non-test file in internal/ and cmd/, traced each spec
section to its call site, and checked whether the live path (main.go →
router → coordinator → adapter) actually reaches it. This file is the single
merged record — a prior split between AUDIT.md (audit + plan) and
progress.md (chronological session log) has been flattened here; treat any
older chronological git history of either file as session notes, not ground
truth.
Ground truth rule for future sessions: this repo has a documented history of code that looks wired but isn't (packages with tests that pass in isolation while the live call path silently no-ops). Before trusting any "done"/"fixed" claim below, check the actual call site, not just this document.
Current verdict (as of 2026-07-29)
The core code and targeted tests are substantially ahead of the deployed
operator surface. The live API is reachable over HTTP at orchestra.kvmx.ru
(HTTPS currently returns nginx 502), but its historical test-e2e backlog
is not an operationally useful control plane yet.
Live evidence, checked 2026-07-29
- One
workpc-opencodeworker is heartbeating and reports capacity 1. Its Unix-socket herdr path is the meaningful reachability signal; legacy coordinator TCP probes are not. - The API reports 28 tasks: 22 blocked, 5 completed, 1 failed, and no queued, leased, approval-pending, or captured sessions. The visible queue is therefore historical E2E residue, not current work.
- A real browser loaded the deployed SPA with a valid Web session and crashed
before painting:
overview.sessionsis JSONnull, while the bundle callssessions.length. Task detail has the same fault forevents: null. Source now emits empty arrays and the frontend is defensive; the current local build renders the live board and task record. The deployment still needs a rebuild/redeploy. - The task record for a blocked task and the failed E2E task had no retained session and no lifecycle events. Every lifecycle control was correctly disabled, but neither record could explain its state.
- The live image predates the source session/logout work:
DELETE /v1/ui/sessionreturns405. Do not treat token rotation as session revocation until that endpoint is deployed.
Earlier successful OpenCode approval and worker-flow checks remain useful historical evidence, but they do not prove the current deployment or a successful worker-owned handoff/release/pickup. Cross-machine continuity is still deliberately incomplete; Design A must not operate a non-local checkout.
Operator UI: remaining work
The current UI should become a diagnosis surface, not a five-column task catalogue.
- Persist and show a blocked diagnosis. Project the structured blocking reason/error into the task read model. Group the board by reason and age (for example: lease failure, worker offline, expired lease, approval, handoff validation, operator block) rather than rendering a giant generic “Blocked” lane.
- Persist last-session and pane evidence. Before a worker/coordinator
drops a terminal session mapping, retain harness, pane ID, last successful
capture/check, and a pane status of
open,closed,unreachable, orunknown. The UI must show the source and timestamp. For old records that lack this evidence, say unknown — legacy record has no retained blocker/pane evidence, never imply a live or closed pane. - Make the task page lead with the diagnosis. Put reason, last activity, pane state, and next safe action first. Hide unavailable lifecycle forms behind an “Unavailable actions” disclosure; an empty session must not consume most of the page with disabled controls.
- Separate active work from history. Default the board to active, waiting, and needs-attention work; move completed/failed/test residue to filters or a compact history view. Add search, project filtering, and an explicit “no live work” state.
- Complete the worker truth model. Show worker-owned heartbeat, local herdr reachability, active task/pane, and last error separately from legacy coordinator probes.
- Deploy and verify the browser path. Ship the null-collection fixes and
session logout endpoint, then record real login, refresh, expiry, board,
task-detail, and approval browser flows. Keep
web/distand embedded assets synchronized as part of that build.
Blocking defects
The sections below are retained defect provenance and implementation detail. Their dated deployment claims do not supersede the current verdict above. Read them when changing the affected path; use the current verdict for the live state and remaining operator work.
B18 — the web UI surface is unauthenticated (found by audit 2026-07-28, uncommitted working tree) — closed (code fix; requires an env change before restart)
cmd/orchestra/main.go:205 mounts the browser read/write surface with no
handler-local credential check:
mux.Handle("/v1/ui/", ui.Server{Store: s, Workers: workers, Coordinator: coordinator, ...}.Handler())
Unlike the worker/harness endpoints on the same mux (main.go:342, 372, 467, 821, 840), it performs no check of its own and stamps its events
Surface: string(authz.Web) (internal/ui/ui.go:222, 337, 368).
It is not, however, ungated in principle. authz.HTTP wraps the entire
mux (main.go:1167) and defaults a request with no X-Orchestra-Surface
header to Web, so /v1/ui/ is covered by ORCHESTRA_WEB_TOKEN. The
defect is that this gate is opt-in — authz.HTTP's own comment says
"authentication is optional for local development," and expected != "" is
the only thing standing between the LAN and the surface.
The live deployment has opted out. .orchestra-config/orchestra.env
sets no ORCHESTRA_*_TOKEN at all, and the server binds ":"+port
(all interfaces, 9145). So on the deployed homesrv instance this surface is
in fact reachable unauthenticated from the LAN.
Consequence. Unauthenticated reachable actions include TaskCreated,
and per-task release, block, complete and handoff
(ui.go:345-361). Most seriously, grant_approval / deny_approval
(ui.go:301) terminate in pane.send_text against a live pane — so an
unauthenticated caller can inject keystrokes into a running agent session on
workpc. B14 was filed because Orchestra could accidentally advance a
permission dialog; this surface allows it deliberately, from the network.
Why simply setting ORCHESTRA_WEB_TOKEN does not fix it.
mux.Handle("/", webui.Handler()) serves the SPA from the same listener,
behind the same middleware. A browser cannot attach an Authorization
header to a top-level document load, so setting the token returns 401 for
index.html and the web UI stops loading entirely. Bearer-token auth and a
browser-served SPA are incompatible as currently wired; that is likely why
no token is set.
Required fix (design decision, not a one-liner). The browser needs a
credential it can actually present on a document load — a login endpoint
that exchanges the Web token for an HttpOnly, SameSite=Strict session
cookie, with authz.HTTP accepting either that cookie or a bearer token for
the Web surface. Alongside it: make the token mandatory rather than
opt-in whenever the UI is mounted (refuse to start, rather than silently
serving an open control plane), and bind to loopback by default so exposure
is a deliberate configuration act. Approval commands in particular must not
be reachable without it.
Fix (2026-07-28):
- The token is now mandatory (
cmd/orchestra/main.go): startup callslog.FatalifORCHESTRA_WEB_TOKENis empty. Serving the control plane openly is no longer something a missing env var can cause silently. authz.Sessions(internal/authz/authz.go) issues 32 random bytes as a session value and stores only its SHA-256, so a leaked snapshot of the map yields nothing usable. Sessions expire (12h default) and are swept on each issue.POST /v1/ui/sessionverifies the Web token in constant time and sets the value as anHttpOnly,SameSite=Strict,Securecookie.Secureis dropped only ifORCHESTRA_UI_INSECURE_COOKIEis set, which is required to reach the UI over plain HTTP.authz.HTTPWithSessionsaccepts that cookie in place of the bearer token, and only for theWebsurface; the bearer comparison is also now constant-time. Two paths are exempt from the gate, both necessarily: the login endpoint (which performs its own check — gating it would make login unreachable) and non-/v1/GET/HEAD, i.e. the SPA shell and its static assets, which are not secrets. Every/v1/control path stays gated.
TestWebSessionCookieGatesControlPathsOnly asserts the whole shape: an
uncredentialed control path is 401, the shell and login endpoint are
reachable, a valid cookie authorizes, a forged one does not, and a cookie
presented on the TUI surface is rejected. TestSessionExpires covers TTL.
Operational precondition — this is a breaking config change.
.orchestra-config/orchestra.env currently sets no tokens, so
orchestra.service will refuse to start until ORCHESTRA_WEB_TOKEN is
added (plus ORCHESTRA_UI_INSECURE_COOKIE=1 if the UI is served over plain
HTTP). Setting a Web token also newly gates the other /v1/ surfaces that
default to Web — any existing unauthenticated client of this instance will
begin getting 401s and needs the token too. Do not restart the service
without making that env change first.
Still open: the listener binds all interfaces (":"+port); loopback-by-
default was considered and deliberately not taken, so exposure remains a
network-level concern.
B14 — agent.prompt can duplicate a task prompt after an ambiguous wait timeout (found live 2026-07-28) — closed (code fix; not re-verified live)
Live Claude E2E task 06FTF8CPH3K3DPN6XQA7G3WRQ8 (pane wM:p1) received
the same initial Begin Orchestra task ... prompt twice. Claude completed a
first turn inspecting the repository and asking for task context, then began
working on the identical prompt again after the turn stopped. This was
observed directly in the pane transcript; it is not merely a UI replay.
Root cause: CLIAdapter.Lease calls Client.Prompt with
wait.until=idle and a 60-second wait. Client.Call, however, applies the
client's default 10-second connection deadline when the caller context has
no deadline. Once that local read deadline expires, Prompt treats the
result as any other transient failure and retries agent.prompt with the
same payload for up to bootRetryWindow. Herdr may already have accepted
the first request, so this is an ambiguous outcome, not a safe retry.
Consequence: an agent can repeat work, waste context/quota, or overwrite its own in-progress changes. The initial task prompt is especially exposed because it deliberately waits for the agent to become idle.
Additional live consequence — OpenCode permission bypass (2026-07-28):
the OpenCode E2E task 06FTF9Z8RP5FM4F9M2SKAQNFZ4 was leased to
workpc-opencode in /tmp/test-e2e-worktrees/06FTF9Z8RP5FM4F9M2SKAQNFZ4.
An operator directly observed OpenCode stop at the interactive permission
dialog for its initial git log --oneline -10 && git status --short command,
then advance and run that command without operator input. Workpc herdr's
live log records Orchestra's agent.prompt calls at 07:37:09 (request IDs 6
and 7), 07:37:10 (ID 8), and 07:37:20 (ID 9); the last two only ended when
Orchestra's 10-second RPC deadline disconnected the client. Herdr labels
each as changes_ui=true. The active OpenCode manifest contains only
permission-detection rules and no auto-approval rule. The task later
blocked at the next prompt (~/.claude/RTK.md) when no further prompt retry
advanced it.
This does not prove which internal herdr action produced the equivalent of
Enter, but it proves the safety boundary is broken: an Orchestra-originated
UI-changing agent.prompt operation can advance an OpenCode permission
dialog. Treat this as an authorization bypass, not merely duplicate work.
Required fix: make the RPC deadline at least the requested herdr wait
(plus a small transport margin), and do not blindly resend agent.prompt
after a timeout or other ambiguous post-write error. Record an observable
failure instead, or use an idempotency/acknowledgement mechanism if herdr
adds one. In addition, never issue agent.prompt while agent_status is
blocked (or a pane read matches a permission dialog); treat the condition
as requiring explicit operator approval. Do not run further unattended
OpenCode E2E tasks until both controls are verified live.
Fix (2026-07-28), in Client.Prompt (internal/herdr/herdr.go:280):
- The RPC deadline is now derived from the requested wait, not the client
default: when the caller's context has no deadline within
wait + 5s,Promptinstalls one (herdr.go:307-314). A 60swait.until=idleno longer expires against a 10s transport deadline. - Retry is split by error class. A JSON-RPC protocol error is herdr's
explicit rejection before it acted and stays retryable for the bounded
boot window; a transport failure after the write is ambiguous and is
never resent (
herdr.go:315-325). Covered byTestPromptDoesNotRetryAmbiguousDelivery, which closes the connection mid-agent.promptand asserts exactly oneagent.promptis issued. - The authorization-bypass half is addressed by inspecting the pane before
prompting:
Promptrefuses a pane whoseagent_statusisblocked(herdr.go:285) and refuses any pane whose transcript matchespermissionPrompt(herdr.go:292). Approval is now an explicit, separately authorized operation — see the command-channel section below.
Not re-verified live. The original live reproduction was cross-machine, and the "do not run further unattended OpenCode E2E tasks until both controls are verified live" instruction above still stands.
B15 — a coordinator-side block cannot reconcile a later live completion (found live 2026-07-28) — closed (code fix; operator-mediated, not re-verified live)
The same OpenCode E2E task 06FTF9Z8RP5FM4F9M2SKAQNFZ4 was marked
TaskBlocked when the coordinator's agent.prompt RPC timed out, despite
the agent having received the task. The operator later explicitly approved
the one pending ~/.claude/RTK.md read through herdr; OpenCode completed the
verification and became idle in pane wN:p1. Orchestra still showed
state: "blocked" because Coordinator.Start returned through block()
and never persisted a session; its monitor therefore has no live pane to
observe or reconcile.
Consequence: the event log can permanently claim a task is blocked when the leased harness has actually completed it. This is not merely stale UI: blocked tasks have no normal completion hook path and their receipt/quota record is lost unless an operator manually corrects the lifecycle.
Required fix: retain enough launch/session identity after an ambiguous
lease failure to reconcile agent.get/completion evidence, or introduce a
versioned TaskCorrected terminal-state workflow that records the original
blocker and evidence without pretending it never happened. A live pane must
not be left outside the coordinator's session map simply because prompt
delivery timed out.
Fix (2026-07-28), in Coordinator.Start
(internal/orchestrator/orchestrator.go:911-922): when the lease call
fails but a pane was actually created, the session is completed with
HerdrID, TaskFileSHA and ConventionsHash and persisted via
rememberSession before block() records TaskBlocked. Reconcile
(orchestrator.go:421) explicitly retains mappings for StateBlocked as
well as StateLeased, so a restart does not orphan them either. The live
pane therefore stays observable through Capture and the monitor.
Scope limit — reconciliation is operator-mediated, not automatic.
Nothing promotes a blocked task to completed on its own; the router still
never retries a blocked task. What exists now is the evidence and the
surface: the retained session plus the complete / release / handoff
actions on the UI task endpoint (internal/ui/ui.go:345-361), and the
pre-existing versioned TaskCorrected workflow (S8) for recording the
correction without pretending the block never happened. That satisfies the
"must not be left outside the session map" requirement; it does not
implement autonomous reconciliation, which remains unbuilt by choice.
B16 — hardcoded harness name makes each harness globally single-instance (found live 2026-07-28) — closed (code fix; contract test only partially satisfied)
While starting the real OpenCode healthcheck task
06FTFDW22833F1CCB8K43Z8808, Orchestra created worktree/pane wP:p1 but
herdr never attached an agent. A direct retry exposed the actual cause:
agent.start was sent with name: "opencode", which herdr rejected with
agent_name_taken because the completed prior E2E agent in wN:p1 already
owned that name. This is not a herdr limit of one OpenCode process per
machine: retrying the same kind: "opencode" in wP:p1 with the valid,
unique lowercase name oc-06ftfdw22833f1cc immediately started a second
attached, idle OpenCode session while wN:p1 remained intact.
Root cause: Client.StartAgent passes the harness kind as both
kind and name; every OpenCode lease therefore competes for the same
global agent-name slot. The same defect applies to Claude and Codex.
Required fix: keep kind as the configured harness, but derive name
from a validated, bounded task/session identifier (lowercase letters,
digits, -, _, maximum 32 characters) and persist it in Session for
subsequent lifecycle operations. Add a contract test that starts two same-
harness sessions in distinct panes and verifies both attach.
Fix (2026-07-28): agentName(harness, taskID)
(internal/herdr/herdr.go:452) derives a bounded, validated name — a
per-harness prefix (oc/cl/cx), the lowercased task ID with everything
outside [a-z0-9_-] collapsed, capped at 32 characters. Over-long IDs keep
a leading stem plus an 8-hex-character SHA-256 suffix, because plain
truncation made distinct long task IDs collide in herdr's machine-global
namespace. StartAgent passes it as name while kind stays the
configured harness (herdr.go:393), persists it as Session.AgentName
(herdr.go:145, 445), and binds it for prompt routing (herdr.go:446,
adapter.go:144). Pane-scoped operations deliberately keep using
PaneID — AgentName is only for agent-targeted calls (adapter.go:531).
Requirement not fully met: the requested contract test does not exist.
What is covered is name derivation — TestAgentNameIsBoundedAndValid and
TestAgentNameLongIDsDoNotCollide — plus TestPromptDoesNotRetryAmbiguous Delivery asserting the prompt target is the unique name. Nothing yet starts
two same-harness sessions in distinct panes and verifies both attach, so the
agent_name_taken failure mode itself is untested end-to-end.
B17 — opaque harnesses must not author canonical handoff anchors (found live 2026-07-28) — closed in code; live success still unproven
The real OpenCode healthcheck run showed that prompting an opaque agent to
write the full continuity.Handoff schema is the wrong ownership boundary.
The agent wrote JSON that looked plausible but used anchor.sha instead of
anchor.git_sha, omitted required top-level fields by nesting them under
knowledge, and could not correctly represent the uncommitted handoff file
as a dirty-file hash (a file cannot contain the SHA-256 of its own final
contents). Repeated corrective prompts made the handoff less reliable and
turned Orchestra's protocol details into agent prompt lore.
Required direction: prompt a harness exactly once for a small semantic
handoff/review report (what changed, validation evidence, remaining work,
review findings). The worker that owns the checkout must then deterministically
collect HEAD, branch, dirty paths and SHA-256 values, construct the canonical
handoff artifact, and validate it before publication. Canonical handoff files
are worker-owned protocol state and must be excluded from their own dirty-file
list. Project-level commands and stable expectations (build, test, file
ownership/invariants, etc.) belong in AGENTS.md/CLAUDE.md; only task-
specific evidence belongs in the handoff.
This cannot be safely implemented in Design A by homesrv: its coordinator does not own the workpc checkout. Expand the bridge into a real worker-side continuity participant (the deferred Design B direction) so all Git-derived handoff facts are gathered and sealed on the machine that hosts the worktree.
Live evidence (OpenCode healthcheck/review run, 2026-07-28): task
06FTFDW22833F1CCB8K43Z8808 initially hit B13/B16: Orchestra created wP:p1
but its hardcoded name: "opencode" conflicted with the prior E2E session.
Starting the same kind: "opencode" with unique name oc-06ftfdw22833f1cc
attached successfully without closing the prior pane. The implementation
agent created executable scripts/healthcheck.sh, validated bash -n, its
normal mode, and --help, then committed 672c123 Add scripts/healthcheck.sh.
It first authored a lookalike handoff (anchor.sha, incomplete meta, and
all protocol fields under knowledge). A distinct reviewer in a second pane
(wP:p2) independently repeated the checks, noticed the nesting error, and
rewrote the document. Its claimed schema validation checked only JSON syntax
and the presence of keys — it did not use continuity.Decode or the
actual typed contract: test remains an array where Handoff.Test is a
string, and last_result remains a string where Handoff.LastResult is a
structured object. This is a concrete proof that a reviewing harness cannot
be trusted to validate canonical protocol state by visual shape alone.
The task is deliberately still TaskBlocked from its original failed
automatic attach even though its manually-started implementation/review panes
are live, a second live confirmation of B15. The reviewer pane is left open
for operator inspection; do not close or release it without approval.
Related live observation — unexpected Claude launch explained: Claude
pane wM:p1 did not start spontaneously. At 08:00:41Z the expired lease for
task 06FTF8CPH3K3DPN6XQA7G3WRQ8 emitted TaskReleased(reason: "lease_expired"); the router re-leased it at 08:00:42Z, and workpc herdr
attached Claude at 08:00:43Z. The renewed attempt then hit B14's prompt
timeout and became blocked. This is expected retry behavior, but illustrates
why lifecycle retries must be visible to operators.
Status: satisfied on the worker path.
cmd/orchestra-worker now implements the required ownership boundary. The
harness is asked once for a bounded semantic report; releaseReady
(cmd/orchestra-worker/main.go:180) then runs CLIAdapter.Release and
herdr.HeadSHA on the machine that hosts the worktree, so HEAD,
branch, dirty paths and SHA-256 values are collected locally and the
canonical artifact is validated before publication. The handoff-provenance
correction documented at the end of this file supplies the parsing and
validation half.
The coordinator no longer accepts a non-local herdr operation; the standing rule is enforced rather than advisory. A successful live worker handoff, release, and pickup remains the required operational proof.
QA attempt (2026-07-29): the bounded disposable task
06FTSHBPYHQXN8MM849PFA1V6M reached the worker and wrote its semantic
report, but publication correctly refused it. Its prescribed fields combined
to the canonical action inspect the marker file — verify worker-owned handoff construction; the circular-action guard rejects that word even when
it occurs in the explanatory WHY text. This is an invalid QA fixture, not a
successful handoff or a release-path regression. The worker must be given a
fresh fixture whose combined NEXT/WHY action does not mention a handoff
(for example, WHY: verify canonical artifact construction), then the
release and successor pickup must be observed. The coordinator is token-gated
and this host cannot read the protected worker credential, so this session
could not enqueue that replacement task. The failed disposable pane was left
untouched; no destructive herdr calls were issued.
B13 — agent.start silently no-ops under back-to-back leases (found live 2026-07-28) — closed (code fix; not yet re-verified live)
Discovered while live-verifying the B12 fix below. B12 itself is confirmed fixed (see that entry), but re-testing it exposed a second, deeper defect that B12's retry logic cannot address.
Three fresh tasks were leased in quick succession against workpc-claude
(06FTAKSJZTB73FZQE3QT7XQ1J0/pane wD:p1, 06FTAMFKVEPGPKR0H86C08ZZNG/pane
wE:p1, 06FTANAYZPA55BABR2J4MWQ050/pane wF:p1). All three got a
worktree.create success and an agent.start call that returned no
error. Checked live via pane.get/pane.list (and independently
confirmed by the user cd'ing into each worktree by hand):
wD:p1(the first, given time alone to settle): a realclaudeprocess did eventually attach —agent: claude,agent_status: idle,revision: 2. This is consistent with B12's model:agent.startacknowledges the request but the actual attach is asynchronous, sometimes taking well over a minute.wE:p1andwF:p1(started moments afterwD:p1, while it was presumably still initializing): still no agent attached after 2+ minutes of polling —agent_status: unknown, noagentfield,revisionnever incremented past its creation value. Empty shells, confirmed both viapane.getand by the user directlycding into the worktree and finding no session running.
So agent.start's "success" is not just delayed for these two — it never
happened at all, and agent.start never returned an error to tell Orchestra
that. One plausible cause: something about launching a second/third claude
CLI process while a prior one on the same host/account is still mid-startup
(auth handshake, config lock, etc.) silently drops the later request(s)
rather than queuing or erroring them. Not confirmed — would need a
controlled single-at-a-time repro to isolate from herdr's own internals,
which requires spinning up more real panes and wasn't done this pass (see
"three orphaned panes" below).
Consequence: unlike B12 (which at least produced an observable
TaskBlocked), this failure mode leaves the task leased indefinitely against
a pane that will never produce a session, with no error surfaced anywhere —
worse than B12 was, because nothing currently distinguishes "still booting,
give it more time" from "silently dead, will never start." A client-side
retry on agent.start itself (B12's approach) cannot fix this, since the
call that should have started the process already returned success with no
error to retry on.
Fix: since agent.start's own return value can't be trusted, StartAgent
(internal/herdr/herdr.go) no longer treats its success as the end of the
story. After agent.start returns without error, it polls pane.get({pane_id})
(new paneAgentAttached helper) until the pane reports a real attached agent
(agent non-empty and agent_status present and not "unknown"), bounded by
a new agentAttachWindow (90s, longer than bootRetryWindow's 15s since a
legitimate attach was observed live taking "well over a minute"). If the
window elapses with no attach, StartAgent now returns an explicit error
instead of a false success — this is exactly the "recorded/observable
failure over silent continue" pattern this repo's CLAUDE.md calls for, and
should surface as TaskBlocked through the same path B12's fix already
proved reachable.
Not yet done: re-verified against a live herdr instance (the schema for
pane.get's result — agent/agent_status fields — was inferred from
prose in this file's own B12/B13 narration of live pane.get output, not
re-confirmed by a fresh probe of 192.168.1.105:9245; deploy/herdr-schema.json
has no entry for pane.get's result shape). go build/go vet/go test ./... all pass, but no test exercises StartAgent's new polling loop
directly (existing internal/herdr tests don't call agent.start/pane.get
through the fake TCP listener at all). Next session should confirm the
pane.get field names live before trusting this closes B13 operationally,
and ideally fire a fresh back-to-back-lease test against workpc once that's
confirmed.
Side effect of this investigation — three live orphaned panes on workpc,
left untouched on purpose: wD:p1 (has a real but abandoned claude
session, task now stuck in blocked from before B12's fix), wE:p1 and
wF:p1 (empty shells, no agent ever attached). Not cleaned up — same
standing rule as the pre-existing wA:p1 stuck task: don't call
pane.close/pane.release_agent against live panes without asking first.
B12 — StartAgent races the pane's shell readiness (found live 2026-07-28) — closed (this specific race; see B13 for a second issue it exposed)
Discovered during the first real end-to-end run against a freshly rebuilt/
redeployed binary (the running service had been on stale pre-audit commit
325c684 the whole time).
Created a fresh task (06FTAH4MCRCAJYY7V75Z4V2YGG, project test-e2e,
capability opencode). It reached TaskLeased onto workpc-opencode
(worktree created for real at /tmp/test-e2e-worktrees/<task-id> on workpc,
confirmed via a live pane.list — pane wB:p1, correct cwd), then
445ms later hit TaskBlocked:
lease: herdr protocol error: agent target pane wB:p1 is not an available shell
internal/herdr/herdr.go:199 (StartAgent) calls agent.start on the pane
Worktree just recorded, with no wait or retry after worktree.create
returns. herdr hands the pane back before its shell has actually finished
initializing, and agent.start refuses it as not-yet-a-shell. None of B1–B11
exercised this — every prior fix assumed a session already existed
(occupancy, rotation, handoff); nothing in the remediation plan tested the
very first Lease call against a real herdr pane end-to-end. Phase 0's
probing used params:{}/missing-field tricks, never a real
worktree.create → agent.start sequence timed against actual pane boot.
Reproduced a second time, 2026-07-28, same session: a second fresh task
(06FTAHW8GZP0S6DCHEQ6PJXWB0) leased onto a brand-new pane (wC:p1) and hit
the identical error on the same sub-second timeline. Two for two — a
systematic race, not a flake.
Consequence: every fresh lease that hits this race goes straight to
TaskBlocked, and the router never retries a blocked task
(internal/router/router.go has no reference to StateBlocked at all,
confirmed by grep) — so it sits there permanently until a human intervenes
via the approval surface. This is now the single blocking defect for proving
the rest of the remediation plan against a real live task.
Fix, iterated live across three rounds, 2026-07-28:
- First pass: bounded-retry
agent.start(10×, 500ms apart) specifically on the"not an available shell"string. Redeployed and re-tested against a real fresh lease — this cleared that exact error, but the very next call in the sequence (Lease's post-agent.startPrompt) then hit a different transient error,"agent ... is not an active named agent"— same underlying readiness race, one call later, worded differently. - Second pass: added the identical string-matched retry to
Promptfor that specific message. Redeployed, re-tested — this time hit a third distinct wording,"agent target ... not found", on the same call. - Given three different error strings for the same race across three live
attempts, string-matching was abandoned as unwinnable (herdr's wording for
"not ready yet" isn't fixed enough to enumerate). Final shape: both
StartAgent'sagent.startcall andPrompt'sagent.promptcall now retry on any error, bounded by wall-clock time (15s,bootRetryDelayof 500ms) rather than attempt count or specific wording — there is no other legitimate reason a call against a pane/worktree orchestra itself just created would fail immediately.StartAgentstill special-cases"already"as a non-error (idempotent re-lease).
Confirmed this closes the original race: task 06FTAKSJZTB73FZQE3QT7XQ1J0
(pane wD:p1) leased successfully, and polling pane.get live shows a real
agent: claude / agent_status: idle attach — independently confirmed by
the user cd-ing into that worktree by hand. go build/go vet/go test ./... all pass throughout.
Not fully clean, however: re-testing this fix by firing two more tasks
shortly after the first one surfaced a second, separate defect — agent.start
returning success while never actually starting an agent, with no error for
the retry logic to even see. That's B13, above — this entry only covers
the specific shell/agent-readiness race B12 was originally filed for, which
is fixed; B13 is a distinct root cause the same investigation exposed.
Web UI and the capture / approval command channel (uncommitted, 2026-07-28)
Roughly 520 lines of Go across eight tracked files, plus the untracked
internal/ui, internal/webui and web/ trees, were added after the last
commit and were previously undocumented here. Recording the design so the
next session does not have to re-derive it.
Capture/command model (internal/federation/federation.go). Two new
worker-scoped maps on Registry. PutCapture stores the latest pane text
per (worker, task) and bumps a monotonic Revision only when the text or
pane actually changes. Queue accepts only grant_approval /
deny_approval, and requires a non-zero CaptureRevision — a command is
bound to the exact capture an operator was looking at. CompleteCommand
refuses to resolve a command twice.
Worker side (cmd/orchestra-worker/main.go). publishCaptures pushes
recent pane text for every held session; runCommands polls
/v1/federation/commands and, for each command, re-reads the pane,
re-publishes it, and refuses with stale if the resulting revision differs
from the one the command was issued against. approvalResponse decides the
keystroke: it sends y/n only for a visible [y/n]/(y/n) prompt, and
sends bare Enter only for OpenCode's fully-labelled Allow once / Allow always / Reject … enter confirm selector — where Enter is a bounded
one-time grant. It refuses to deny through that selector, because doing so
would require unobservable navigation. Unknown dialog layouts are rejected
outright rather than guessed at. Both branches are covered by
TestApprovalResponseOpenCodeAllowOnce and
TestWorkerApprovalCommandIsRevisionBoundAndAcknowledged.
This is the right shape for B14's authorization half: approval becomes an explicit, revision-bound, separately-issued operation instead of a side effect of prompting, and it executes on the worker that owns the pane rather than over a coordinator-driven remote socket.
Coordinator side. Coordinator.RespondApproval and
herdr.ApprovalResponder (internal/herdr/adapter.go:602) are the
local-herdr equivalent, applying the same re-read-before-send rule via an
expectedCapture comparison and the same y/n-only refusal.
Coordinator.RequestHandoff exposes the handoff request as an operator
action without releasing the pane.
Known weaknesses of this channel. The defects found in it are filed
individually below — B19 (federated approvals emit no event), B20
(the local capture revision is a timestamp, not a change counter) and
B21 (Registry.commands never prunes). Two further gaps remain:
RespondApproval(coordinator path) has no test, unlike its worker counterpart — and per B20 it is the path whose revision is meaningless, so its text-comparison guard is the only thing actually binding the decision to what the operator saw. That guard is untested.web/is a full Vite/React app whose build output is embedded viainternal/webui'sgo:embed assets/*. The embedded assets are checked in as build output; there is no documented step tyingweb/sources to a rebuild ofinternal/webui/assets, so the two can silently diverge.
Found while implementing B18 (2026-07-28) — all fixed the same day
These were noticed while wiring the session gate, and all five were fixed in the following pass. Each section states the original defect first, then how it was closed.
B19 — federated approvals leave no audit trail — closed (2026-07-28)
Server.action's grant_approval / deny_approval branch
(internal/ui/ui.go:301-341) has two exits that are not symmetric. The
local-coordinator path calls RespondApproval and then appends
ApprovalGranted / ApprovalDenied to the store. The federated path calls
s.Workers.Queue(...), writes the command to the response, and returns
before appending any event.
So the single most safety-critical operation in the system — injecting a keystroke that advances a permission dialog on another machine — is invisible in the event log precisely when it crosses a machine boundary. There is no record of who approved what, or that an approval happened at all. §7.1's authorization model assumes the event log is the record.
Fixing this needs a decision the local path did not have to make: the
command is queued, not executed, so the honest event is a request at queue
time plus a resolution when the worker acknowledges (CompleteCommand
already carries acknowledged / rejected / stale). Emitting
ApprovalGranted at queue time would claim an outcome that has not
happened yet.
Fix. The resolution half already existed and this entry understated it:
/v1/federation/commands/ in cmd/orchestra/main.go appends
ApprovalGranted/ApprovalDenied — but only on status == "acknowledged",
so a queued click never claims an outcome. What was missing was the request
half. Server.action now appends ApprovalRequested at queue time, with
subject_ref set to the command ID that the later resolution event carries,
plus decision, worker, pane_id and capture_revision. If that append
fails the queued command is immediately resolved rejected, so the worker
can never execute a keystroke that left no audit trail. rejected/stale
outcomes still emit no resolution event; the ApprovalRequested stays
unresolved and surfaces in operations.NeedsAttention, which is the honest
reading. Covered by
TestFederatedApprovalIsQueuedAtTheWorkerRevisionAndAudited.
B20 — the local capture revision is a timestamp, not a change counter — closed (2026-07-28)
Server.capture (ui.go:80) fabricates Revision: uint64(time.Now().UnixNano()) for the coordinator path. The federation
path's revision is a real per-change counter (Registry.PutCapture bumps it
only when pane text or pane ID actually changes), and the worker's staleness
check depends on that meaning.
Two consequences:
- The local revision changes on every read, so it conveys nothing about
whether the pane changed. The local path is still safe, but only because
RespondApprovalre-reads and compares capture text — the revision it reports to the browser is decorative. - If a task has both a live coordinator session and a published worker
capture,
capture()prefers the coordinator (ui.go:77) and returns a timestamp revision, whichaction()then passes toQueueasCaptureRevision. That value can never equal the worker's counter, so the worker resolves every such commandstaleand the approval silently never happens. The precedence between the two capture sources needs to be explicit rather than incidental.
B21 — Registry.commands is append-only — closed (2026-07-28)
r.commands[worker] is only ever appended to (federation.go:119);
CompleteCommand flips a status in place and nothing is ever deleted.
Resolved commands accumulate for the process lifetime, and Commands()
rescans the entire history on every worker poll. Combined with the
in-memory-only storage already noted, the lifecycle is wrong at both ends:
it forgets across restarts and never forgets within one.
Fix. Registry.pruneCommands drops resolved
commands older than CommandRetention (30 minutes), running on both Queue
and Commands, so the per-worker list is bounded and the poll path no
longer rescans unbounded history. Pending commands are never pruned, at any
age — dropping one would silently discard an operator decision. Captures,
commands, and the worker identity/token binding now persist atomically in
$ORCHESTRA_DATA/federation-state.json (mode 0600), so the worker must
re-register with its original token after restart before it can consume a
recovered command. Covered by TestResolvedCommandsArePrunedButPendingOnesSurvive
and TestPendingApprovalSurvivesRegistryRestart.
S12 — ORCHESTRA_NTFY_TOKEN serves two unrelated purposes — closed (2026-07-28)
The same env var is the ntfy server credential (main.go:1153,
delivery.Ntfy{Token: ...}) and the authz surface credential for
authz.Ntfy (main.go:1165). These are unrelated secrets with different
trust boundaries: one is handed to a third-party notification server, the
other authenticates callers to Orchestra. Setting the former silently makes
it a valid inbound credential. They need separate variables.
Fix. The authz.Ntfy surface token is now ORCHESTRA_NTFY_SURFACE_TOKEN;
ORCHESTRA_NTFY_TOKEN is once again only the credential handed to the ntfy
server. Documented in deploy/orchestra.env.example. Operator action:
any deployment that relied on the old dual use must now set the new variable
explicitly, or the ntfy surface reverts to having no inbound gate.
S13 — dead auth() middleware in cmd/orchestra/main.go — closed (2026-07-28)
func auth(next http.Handler) (main.go:1212) is defined and never
referenced; Go does not flag unused functions, so go vet stays quiet. It
reimplements the notify-only rule that authz.HTTP already enforces. It is
harmless today and a trap tomorrow — a second, divergent copy of the
authorization policy sitting next to the real one. Delete it.
Positive note — B8 is more strongly closed than it was
authz.HTTP downgrades a caller-declared system surface to Web before
the token comparison. Previously that landed on a surface whose token was
unset in production, i.e. no gate at all. With ORCHESTRA_WEB_TOKEN now
mandatory, the downgrade lands on a genuinely gated surface, so the B8
bypass is closed by construction rather than by the header check alone.
What's next
In dependency order, not importance order:
- Prove a successful B17 worker handoff. Use a bounded disposable task with a concrete next action and verify canonical report upload, release, follow-up pickup, and cleanup. The rejection path is proven; success is not.
- Fix health semantics and ntfy. Report worker/local-herdr health
separately from legacy TCP registry probes, and correct the ntfy server
credential causing
403 Forbidden. - Address the Web UI backlog above, starting with queue explanations, actionable worker health, and a usable approval/recovery workflow.
- Record a browser smoke. Confirm login, refresh, invalid token, and expired-session behavior in an actual browser after the deployed assets settle.
Deliberately not next: building further on Design A's cross-machine calls, and closing B17 for the coordinator path. Both wait on the federation-fork decision below.
Closed defects
Each entry is the flattened final state — design + what's verified — not the session-by-session narration. Grouped by original defect ID.
B1 — Occupancy measured from the wrong value (§5.2.1) — closed
CLIAdapter.Occupancy was calling a.Usage(s.PaneID) (a herdr pane id)
against readers (ClaudeUsage/CodexUsage/OpenCodeUsage) that want a
filesystem path to session state — every call failed and was swallowed by a
bare continue. Fixed: herdr.Session.SessionFile is resolved at lease time
per harness (Claude: newest-mtime transcript under Claude Code's own project
dir; Codex: existing CodexActiveUsage sqlite discovery; opencode: refuses
loudly rather than guess, since it needs a live session id not resolvable
from the worktree alone). A missing/unreadable session file is now a hard
error surfaced via SessionHealth.Occupancy/OccupancyError on
GET /v1/tasks/{id}/health, never a silent zero.
Still open: live verification against a real Claude Code session at a known context fill — not possible from this sandbox, blocked further by B13 now that fresh leases don't reliably survive.
B2 — Adapter lookup keyed wrong in 3 of 4 call sites (§5.3, §5.4) — closed
AdapterFactory.Herdrs is keyed by herdr instance id (homesrv-claude);
Reconcile/expire/rotate were looking it up by session.Harness (the
harness kind, claude) and silently no-op'ing (continue) on every miss —
orphaned panes never killed, expired leases never killed their pane,
rotation never began. Fixed with a single Coordinator.adapterFor(session)
routed through at all four call sites. Regression test registers an adapter
under a herdr-id key distinct from the harness kind and asserts rotation
still fires.
B3 — Nothing emitted TaskCompleted (§4, §5.2) — closed
Only producer used to be a human calling POST /v1/tasks/{id}/complete.
Added POST /v1/harness/complete: a Claude Code Stop hook
(deploy/hooks/orchestra-stop.sh) fires every turn boundary but only
reports completion once the agent has written a .orchestra-report.md
marker at the worktree root (an ordinary turn boundary is a no-op). The
server builds the receipt itself from herdr.ClaudeUsage against the
transcript (never trusts a self-reported number) and uploads the report
body to CAS for report_ref. Gated by optional ORCHESTRA_HARNESS_TOKEN;
event appended with Surface: system set directly in Go, consistent with
B8. Extended to Codex/opencode via an optional harness field in the
request body ("codex" → CodexUsage, "opencode" → OpenCodeUsage).
Covered by TestHarnessCompletionBuildsReceiptAndQuotaFromTranscript, which
exercises authentication, transcript-derived receipts, TaskCompleted, and
the matching QuotaReported event through the actual HTTP handler.
B4 — Router counted rotation as a retry (§5.3, §5.4) — closed
Every TaskReleased (including rotation, which carries a valid
handoff_ref) advanced MaxAttempts, and lease also double-counted — a task
healthy enough to rotate twice was killed. Now only a release without a
handoff_ref (expiry/crash) advances the counter.
B5 — Herdr protocol methods unverified/invented — closed
Confirmed live against 192.168.1.105:9245 (raw JSON-RPC probing, no local
herdr CLI available — schema reconstructed from Rust serde's
"unknown variant"/"missing field" errors). pane.release, pane.kill,
pane.rotation_signal, pane.status do not exist. Real replacements:
pane.close({pane_id}) for kill (drop-in); pane.release_agent({pane_id, source, agent}) for release — structurally different, does not return
a handoff_ref (herdr never writes handoffs; the agent does, §6.1).
RotationSignal interface/method/call site deleted outright (no replacement
exists; herdr has no concept of Orchestra rotation).
CLIAdapter.Release now: reads the agent-authored .orchestra-handoff.json,
validates with continuity.Decode, cross-checks its anchor SHA against the
worktree's real HeadSHA, re-verifies every Anchor.Dirty file hash,
scratch-commits any dirty files (continuity.ScratchCommit, idempotent
across multiple rotations) and rewrites the handoff's anchor to the new
scratch commit before uploading to CAS via continuity.Save (minting the
real handoff_ref), and only then calls pane.release_agent — sequenced
last so a herdr-side error can't strand an uploaded handoff. Any failure
(missing file, invalid schema, anchor mismatch, stale dirty hash, herdr
error) is a refusal, which rotate already treats as "retry next tick."
CLIAdapter.Lease's bootstrap prompt also fixed to pass time.Minute (not
wait=0) for inline-wait, matching Bootstrap and closing the
send-into-a-half-rendered-prompt race §5.1 requires guarding against.
Covered by internal/herdr/adapter_test.go against a real git worktree and a
fake in-process herdr TCP listener: upload-and-release on a valid handoff,
refusal with no handoff file, refusal on anchor mismatch, refusal on stale
dirty-file hash, refusal with no CAS configured.
Protocol version confirmed live as a bare JSON number (17), not the string
config.jsonc declares — CheckProtocol's raw-bytes fallback happens to
compare correctly today; don't "clean up" that code without re-checking
this, or it may start doing a real numeric-vs-string comparison and break.
Still open: nothing makes the agent actually write
.orchestra-handoff.json unprompted (closed separately, see B6/Phase 4 item
2 below) — that's the piece this entry originally deferred.
B6 — Layer 3 (continuity) was entirely dead code (§6) — closed
Nothing wrote TASK.md, so pickup validation had nothing to check and never
ran. Fixed in full:
GitWorktrees.Createnow writes and commits an immutableTASK.md(continuity.RenderTaskFile) into every fresh worktree;continuity.TaskFileHashreads it back forw.TaskFileSHA.Coordinator.Startrunscontinuity.ValidatePickup(anchor SHA + dirty-file hashes + TASK.md hash) before bootstrapping a successor onto ahandoff_ref— failure kills the session and emitsTaskBlockedinstead of trusting an unvalidated ref.ScratchCommitwired intoRelease(see B5) so pickup collapses to a single HEAD compare instead of per-file rehashing.CLIAdapter.Bootstrap's prompt rewritten to point the agent atgit log/the scratch branch (trust already established by the plane's ownValidatePickup) instead of vague "read the handoff" prose, and deliberately does not claim aGET /v1/artifacts/<ref>endpoint since none exists (/v1/artifactsis POST-only — an earlier draft invented this and was corrected before landing).continuity.MarkdownChanges(zero callers, zero tests despite being listed as implemented in an earlier snapshot) was deleted rather than half-wired. §6.3's actual requirement ("notice to agents whose task is adjacent") was rebuilt independently:continuity.ConventionsHash(root)hashesAGENTS.md/CLAUDE.md/VOCAB.md;Coordinator.checkConventionsruns everyMonitortick, recomputes the project base repo's hash for every leased session, and on drift calls the optionalherdr.ConventionsNotifiercapability (in-pane prompt) once per drift.- Handoff production, the item that most of B6 hinged on:
Coordinator.rotatechecks the adapter's optionalherdr.HandoffRequestercapability; if.orchestra-handoff.jsonis missing, it prompts the agent once (CLIAdapter.RequestHandoff, naming the exact §6.1 JSON shape and explicitly telling the agent not to fabricate SHA/hashes) and skipsReleasethat tick, retrying every subsequent tick — mirrors the.orchestra-report.md/B3 convention exactly.Session.HandoffRequestedavoids re-prompting every tick.
Covered by TestGitWorktreesCommitsTaskFile, TestStartBlocksOnInvalidPickup,
TestReleaseScratchCommitsDirtyFilesBeforeUpload,
TestReleaseRefusesOnStaleDirtyFile, TestConventionsDriftNotifiesActiveSession,
TestRotationRequestsHandoffBeforeReleasing (internal/herdr, internal/orchestrator).
The coordinator no longer supports a herdr-hosted WorktreeCreator path.
It always creates the local, committed TASK.md before leasing a local pane;
remote worktrees are exclusively worker-owned, so coordinator pickup
validation never runs against a remote filesystem path.
B7 — Quota projection had no producer (§7.2) — closed (post-hoc only)
POST /v1/harness/complete now appends a QuotaReported event
(consumed from the same usage.Numerator() used for the receipt), so the
router's 5h/weekly filter and the brief's quota_consumed stop evaluating
against a permanent zero.
Design gap recorded, not yet built — a live push producer, since a
harness that never completes a task cleanly (e.g. the stuck wA pane, see
below) currently under-counts its consumption forever:
- Claude: the statusline stdin JSON already carries real
.rate_limits.five_hour.used_percentage/.seven_day.used_percentage— confirmed by reading~/.claude/statusline.shon this machine. Prefer this overclaude.ai/api/.../usage, which needs browser session cookies, not an API key. - Codex: every
token_countevent in the rollout carries arate_limitsobject withused_percent/window_minutes/resets_at— confirmed directly from a real rollout file. Nocodex usagesubcommand; the rollout tail (orcodex app-server, same events live) is the only transport. - opencode: no first-class quota surface.
opencode statsis historical only. Zen's free-tier daily quota arrives asx-ratelimit-*response headers, not a queryable endpoint — would need an opencode plugin to intercept. - Structural consequence if this is ever built:
sumSince's additive model is wrong for a used-percentage feed (need a secondAvailabilityreading the latest report per harness, not summing); staticquota_limit_5h/weeklybecome unnecessary for Claude/Codex once real fractions are reported;QuotaWindowLimits{FiveHour,Weekly}is too narrow for Codex's self-describingwindow_minutesor opencode's daily Zen window.
B8 — Surface: system unauthenticated bypass (§7.1) — closed
X-Orchestra-Surface: system was reachable from any HTTP request header in
both authz.HTTP and main.go's surface closure — since no deployment
sets ORCHESTRA_SYSTEM_TOKEN, this was an unauthenticated full-control
bypass reachable from any LAN caller. Both call sites now downgrade system
to web before doing anything else with it. System is only constructible
in-process (router leases/failures, coordinator releases/blocks, standup
advisory/apply) via the bus-level authz.AuthorizeEvent check now enforced
inside store.Append itself.
S1 — duplicate JSON struct tags — closed
Brief.From/To and GitSync.Branch/Head/Status each shared one JSON
tag (Go only honors the first json:"..." tag on a combined field
declaration), making go vet ./... fail and the brief's git state
unparseable. Fixed; go vet ./... passes clean.
S2 + S3 — brief git state and completion receipts — closed
Brief.Git used to read from ORCHESTRA_DATA (the event-log dir, never a
git checkout — always "git unavailable") and completions were counted but
never surfaced with proof. operations.Brief.Git is now map[string]GitSync
keyed by project ID, built from registry.Project.Repo (falling back to a
single "default" entry off ORCHESTRA_REPO), with Ahead/Behind vs
upstream added. Brief.Receipts []CompletionReceipt pulls report_ref/
receipt straight out of each TaskCompleted event's existing payload.
S4 — notification goroutine died on first send error — closed
delivery.Fanout.Run used to return on the first sender error,
permanently killing notifications for the rest of the process's lifetime.
Failed sends now go through an OnError hook instead of aborting the loop.
Cursor is persisted (SaveCursor → a delivery-cursor file next to
ORCHESTRA_DATA), so a restart resumes from the last delivered event
instead of re-notifying the entire log from seq 0.
S5 — colliding event IDs on lease — closed
Store.Lease/ExpireLeases set Event.ID to the task id, so every lease of
a task produced colliding event IDs. Now uses domain.NewID().
S6 — ingest dedup returned the wrong event with 200 — closed
Dedup path returned nil (success) without appending; the HTTP handler then
returned an unrelated event with 201. Added domain.ErrDuplicate and
Store.TaskBySource; POST /v1/tasks now returns the existing task with
200 on a duplicate. Every other Append caller (Gitea poll/webhook, JSONL
ingest) now treats ErrDuplicate as expected.
S7 — TaskAmended dropped most fields — closed
Only title was ever applied from an amendment payload; due/
description/inherent_priority were accepted, logged, and silently
dropped by the projection. Also added the missing Task.Description field
entirely (it didn't exist, so TaskCreated dropped it too). Both
TaskCreated and TaskAmended now populate all four fields.
S8 — no compensating-event mechanism (§3.1) — closed
Added TaskCorrected: payload requires corrects (the id of the event it
repairs) plus at least one change (state, or the existing amend-style
fields). Store.Append rejects a corrects that doesn't name a real prior
event on the same task; apply applies the changes and clears Lease like
every other terminal-state branch. Covered by a mistaken TaskFailed
reverted to queued, an unknown-corrects rejection, and both events
surviving snapshot+replay.
S9 — duplicate lease-expiry tickers — closed
Coordinator.Monitor's 30s ticker and main.go's own 1s reclaim ticker both
called Store.ExpireLeases independently. Previously filed as "harmless —
CAS rejects the loser," but the real effect was worse: the coordinator's
expire() is the only place that kills the herdr session/pane for an
expired lease, and the 1s ticker running 30x more often almost always won
the race, leaving the coordinator's own call with nothing left to expire —
silently orphaning herdr panes past TTL. Fixed: main.go's ticker skips
ExpireLeases entirely when a coordinator is configured (deferring reclaim
to the coordinator's loop), keeping only AssignPending as periodic retry.
S10 — federation worker registration had no admission control — closed
Any caller could self-declare a worker id and self-choose a token, and a
second caller could silently re-register an existing worker id with a
different token, hijacking its identity/capacity. Added
Registry.AdmitToken (pre-shared secret via
ORCHESTRA_FEDERATION_ADMIT_TOKEN, checked against the registration
request's bearer header); same-ID re-registration now requires presenting
the existing worker's own token (legitimate restarts still succeed;
different-token same-id is rejected as a hijack).
S11 — soft threshold, milestone, thrash, agent-initiated ROTATE (§5.3) — closed
All four of §5.3's rotation triggers now land:
- Soft threshold:
Coordinator.Soft(default 0.55,ORCHESTRA_OCCUPANCY_SOFT) — bothrotate()andTurnDecisionrequest a handoff once occupancy crossesSoft, beforeHardforces one;TurnDecisionreturnsprepare_handoffadvisory (task stays leased, no turn boundary required). - Agent-initiated ROTATE:
handoffReason(worktree)reads a written.orchestra-handoff.json; ifreason == "manual", bothrotate()andTurnDecisionskip occupancy and the turn-boundary probe entirely and release immediately — the agent's own handoff is the boundary signal. Shared release-and-certify tail extracted intoCoordinator.finishReleaseso this path gets the same anchor-safety guarantee as the threshold path. - Milestone + thrash: needed a transcript/tool-call introspection source
this repo didn't have — built new,
internal/herdr/activity.go:ToolCall{Name, Kind, Key, Success, IsTest}normalizes one tool/function call across harnesses.ClaudeActivitypairstool_use/tool_resultblocks bytool_use_idfrom the transcript already opened for occupancy (an unresolved tool_use — mid-turn — is dropped, not reported).CodexActivity— verified against a real live rollout, 2026-07-28, after the original guessedfunction_call/function_call_outputshape turned out not to exist anywhere in~/.codex/sessions. Real shape: file edits arrive asevent_msg/patch_apply_end(haschanges+ top-levelsuccessdirectly, no pairing needed); shell commands arrive as a freeformresponse_item/custom_tool_callnamed"exec"whoseinputis a JS snippet —codexExecCommandregex-extracts the first embeddedcmd:"..."; failure is signaled by the literal output prefix"Script error:"(confirmed for both a JS syntax error and anapply_patchverification failure on this machine's real transcripts). Manually re-ran the rewritten parser against a real multi-hundred-line rollout end-to-end and spot-checked the output by eye, in addition toTestCodexActivityParsesRealRolloutShape/TestCodexActivityMarksScriptErrorAsFailure.OpenCodeActivityrefuses outright — opencode's on-disk message storage is only confirmed to carry aggregate token counts, not per-tool-call records; fabricating a parser against an unconfirmed shape would repeat the exact mistake this audit exists to catch.DetectThrash(calls, ThrashConfig)implements all three rules: N consecutive failed test runs (regex-matched build/test commands), the same file edited M times (edit tools only, explicitly excludingRead— caught by an initially-failing test), and an identical tool call repeated K times back-to-back (excluding test re-runs and file reads — also caught by an initially-failing test). Defaults 3/5/4, overridable viaCoordinator.Thrash.DetectMilestone(calls)— deliberately narrow: only "the most recent call was a successfulgit commit." Fuzzier definitions (passing test suite, finished subtask) were not guessed at.CLIAdapter.Activityresolves the session file the same wayOccupancydoes;ActivityReaderis an optional Face-B capability like the others.- New
ReasonedHandoffRequester/CLIAdapter.RequestHandoffReasonnames why (thrash's dead ends, milestone reasoning) instead of the generic threshold framing. rotate()/TurnDecisiongeneralize "reason=manual bypasses occupancy" tomanual/milestone/thrashalike:checkActivityTriggersruns before falling through to occupancy/soft/hard (thrash takes priority over milestone), and a hit callsrequestReasonedHandoff— request only, never release, same as the soft-threshold path.
Covered by internal/herdr/activity_test.go and
internal/orchestrator/rotation_test.go's
TestActivityTriggersRequestReasonedHandoffWithoutReleasing,
TestTurnDecision subtests for soft-threshold and manual-reason, and
TestRotationRequestsHandoffBeforeReleasing.
Known remaining limitation: DetectMilestone's single-command
extraction only sees the first cmd:"..." in a chained Codex exec script,
so a git commit issued later in the same script isn't recognized as the
"last call" even though it executed last. Opencode still has no verified
per-tool-call source at all.
Also closed this pass (not separately numbered in the original audit)
- Codex/opencode Stop-hook-equivalent scripts. Neither harness has a
native Stop hook, so the server-side
/v1/harness/turnand/v1/harness/complete(harness dispatch) existed with no caller for either. Addeddeploy/hooks/orchestra-codex-poll.shandorchestra-opencode-poll.sh— background poll loops (default 60s,ORCHESTRA_POLL_INTERVAL) that find the newest session-state file (codex: newestrollout-*.jsonl; opencode: newest file under~/.local/share/opencode/storage/message/), POST to/completewhen.orchestra-report.mdexists, otherwise POST to/turnand log (not act on)refuse/rotate_now— advisory-only, since there's no real turn boundary to refuse at from outside either harness process without deeper app-server/SSE integration. - Bus-level authorization.
authz.AuthorizeEventenforced insidestore.Appenditself (the single choke point every event passes through), not just at HTTP handlers. Event schema bumped to v2, requiring every event to declare aSurface; schema v1 events on disk still replay (tolerant reader). - Dual quota windows.
router.QuotaAvailabilitytracks a 5-hour rolling window and a 7-day weekly window independently per harness, applying the conservative 80% rule to each separately. - Turn-boundary detection made observable. An adapter that fails to
answer
TurnBoundarynow blocks that tick's release rather than treating the failure as "safe to proceed," and incrementsMonitorHealth.TurnBoundaryDegraded. - Cross-machine lease correctness has a primitive-level test.
TestCrossMachineLeaseAnchorAndQuotaArePerHostexercises a lease claimed through the federation worker HTTP API, validates the anchor against that worker's own local checkout, and asserts quota is accounted per-host — proof for the primitives that exist today; does not yet run against two real physical machines (see federation fork, below). - Fuzz coverage (
internal/domain/fuzz_test.go,FuzzValidatePayload/FuzzValidateEvent) for all event types including malformed nested payloads — no panic, always a typed error. - Multi-repo Gitea ingestion.
provider.Giteagained aProjectfield and namespacedSourceName();provider.MultiGiteadispatches bytask.Source;LoadGiteaConfigsloads a JSON array of per-project sources. Legacy single-repo env vars still work unchanged. Previously zero tests existed for the Gitea provider at all (an earlier progress.md claim of coverage was inaccurate) —internal/provider/gitea_test.goadded. - Per-project repos.
registry.Projectgained optionalrepo/worktree_root;main.gobuilds aPerProjectGitWorktrees, falling back to the global default for any project that omits these fields. - Rotation's
TaskReleasedpayload validity.Coordinator.rotateused to build{"handoff_ref","reason"}, omitting theanchor_shathe spec anddomain.ValidatePayloadrequire wheneverhandoff_refis present —store.Appendwould reject it, the error was discarded (if c.Store.Append(e) == nil), and rotation silently never happened, no visible failure. Fixed:herdr.HeadSHA(worktree)populatesanchor_shafrom the real worktree HEAD before appending; if the anchor can't be read, rotation now skips that tick instead of emitting a payload guaranteed to fail validation. The federation worker release endpoint (/v1/federation/workers/{id}/release) had the identical gap and now requires/forwards a 40-hex-charanchor_sha, rejecting with 400 otherwise.
The federation fork — decided 2026-07-27, still the deferred half
Two incompatible federation designs coexist in the tree.
Design A — "drive the remote socket" (clients/herdr-bridge.go) remains
as a legacy byte proxy but is no longer a coordinator execution path. A
multi-machine registry now requires ORCHESTRA_MACHINE_ID; the coordinator
rejects every non-local herdr before leasing, capture, approval, rotation,
release, expiry cleanup, or worktree creation. It always creates the local
committed TASK.md; remote panes/checkouts are worker-owned. This prevents
the old wrong-host git rev-parse, anchor validation, and cleanup failures.
Design B — "workers pull tasks" (/v1/federation/*) is the sole remote
execution path. cmd/orchestra-worker registers, heartbeats, polls/acks
leases, and performs Git/herdr operations on the owning machine; coordinator
state for captures and pending approvals is durable across restart. Worker
offline detection is ticked by the API process rather than depending on a
human health request.
Decision, now enforced: federation workers are the sole remote execution
path. The coordinator-side Design A operations are fail-closed rather than
guarded piecemeal; clients/herdr-bridge.go should be retired from deployment
once no other consumer needs it.
If the worker binary is ever abandoned, delete /v1/federation/* and record
the deviation — leaving both designs in place unmarked is explicitly not
acceptable; the repo would read as though the spec-conformant path is
implemented when nothing can reach it.
Live deployment facts (as of 2026-07-28)
- Runs as
orchestra.serviceon homesrv via this machine's own systemd.homesrvhas no local herdr running (connection refused on 9245) — onlyworkpc's herdr (192.168.1.105:9245) is live and reachable.main.goonly logs herdr connection failures at startup, never successes, so "no log line" for a herdr does not mean it's down. - Stuck task, deliberately left untouched: workspace
wA, task06FT6CKD9Y98AZRX6X8K3QXFZG, opencode harness, panewA:p1. From Orchestra's own point of view it is no longer "stuck" — it now readsstate: "failed"(retries exhausted,MaxAttemptshit before B4's fix landed). But the underlying herdr pane is still live andagent_status: "blocked", confirmed via a directagent.getprobe — the router gave up and moved on, but nothing ever released or killed the actual agent, confirming the orphaned-pane prediction from B2/B5. Left untouched on purpose: user was asked and chose to leave it rather than have it cleaned up mid-audit. Do not callpane.close/pane.release_agentagainst it without asking again first. - First live end-to-end task run (2026-07-28, after rebuilding/redeploying —
the running service had been on stale pre-audit commit
325c684) is what surfaced B12 above, since fixed and confirmed live; re-verifying it surfaced B13, still open. - Three more live orphaned panes from this pass, left untouched:
wD:p1(task06FTAKSJZTB73FZQE3QT7XQ1J0, worktree/tmp/test-e2e-worktrees/06FTAKSJZTB73FZQE3QT7XQ1J0— has a real attachedclaudesession but the task itself is stuckblockedfrom before B12's fix landed),wE:p1andwF:p1(tasks06FTAMFKVEPGPKR0H86C08ZZNG/06FTANAYZPA55BABR2J4MWQ050— empty shells, no agent ever attached, see B13). Same rule aswA:p1: don'tpane.close/pane.release_agentany of these without asking first.
Known open gaps (as of 2026-07-28)
Numbered defects are not repeated here — B19, B20, B21, S12 and S13 are in "Found while implementing B18" above, and the ordering across all of them is in "What's next". This list is the unnumbered residue: conditions that are known, accepted, or not actionable as a single fix.
-
Vikunja must become a first-class automatic task source. The intended path is
Vikunja task → Orchestra task → agent work → Vikunja update, not manual re-entry through the UI. Its provider needs stable external-ID deduplication, explicit list/project and status/label eligibility mapping, and guarded completion/blocker write-back that cannot create an ingestion loop. Treat this alongside Gitea and JSONL ingestion when building the automatic task-source surface. -
B18's env change is applied repo-side only.
.orchestra-config/ orchestra.envnow setsORCHESTRA_WEB_TOKEN, but the unit loads/etc/orchestra/orchestra.env, which is not readable or writable from this sandbox. See "What's next" item 1. -
The listener still binds all interfaces; loopback-by-default was considered and not taken (B18).
-
— diagnosed and fixed 2026-07-28. The flake was in the test, not in assignment.TestAssignsByAffinityCapabilityAndConcurrencyis flakyStore.Tasks()ranges a map, so its order is randomized per call, and the assertion indexed two separateTasks()calls (s.Tasks()[0] ... && s.Tasks()[1] ...); it failed whenever the two orderings disagreed. Instrumenting the failure showed a validTaskLeasedevent and a genuinely leased task on every "failing" run. It now snapshots once and asserts the real invariant — concurrency 1 means exactly one of the two tasks is leased — and passes at-count=60. Worth recording that which of two equal-priority tasks wins is still nondeterministic, sincesort.SliceStableis applied to a randomly ordered slice; that is a real property of the router, not a test artifact. -
B14/B15/B16 are code-fixed but not re-verified under their original live races, and B17 is closed only for tasks that run through a worker. Each was found live, so deterministic coverage is weaker evidence than the failure that produced it; B16's two-same-harness naming contract is now covered by fake-herdr tests.
-
B15's reconciliation is operator-mediated only. Blocked tasks keep their session and are correctable through the UI, but nothing promotes them automatically and the router still never retries a blocked task.
-
web/build output andinternal/webui/assetscan silently diverge; no documented rebuild step ties them together. -
Cross-machine lease correctness proven at the primitive level only — needs an actual homesrv/workpc pair over the real mesh; this repo cannot exercise that by itself.
-
Turn-boundary Face B degrades to occupancy-only for adapters that don't implement it, by design — degradation is observable (
MonitorHealth.TurnBoundaryDegraded) and blocks-on-failure, but whether Claude/Codex/opencode's native hooks are wired in a live deployment is a deployment-config fact, not provable from source alone. -
Quota has no live push producer — only the post-hoc
/v1/harness/completeproducer exists; see B7's "design consequences" for the (unbuilt) Claude statusline / Codex rollout-tail feeds. -
Federation guardrails' landed-status is unverified — see federation fork section above.
-
opencode has no verified per-tool-call activity source and no first-class quota surface (Zen headers only, would need a plugin) — both named, not attempted, in S11/B7.
-
DetectMilestone's single-command extraction can miss a Codex commit issued later in a chained exec script.
Everything else audited (provider layer, continuity schema/validation, router matching, delivery, federation registration primitives) was spot-checked against the code and its tests and matched described behavior.
Handoff provenance correction (2026-07-28)
The previous rotation path had a primacy inversion: the harness wrote a
free-form Markdown “semantic report”, while canonicalHandoff fabricated
the canonical fields around it (goal, done_when, a circular action and a
cat command) and placed the entire report in one remaining element. That
made the apparent schema a wrapper around prose rather than an authoritative
handoff.
The release path now asks the harness for only six labelled, bounded answers:
NEXT, WHY, REMAINING, DEAD ENDS, OPEN Q, and LEARNED, each with a
NONE escape. canonicalHandoff parses those answers and derives only
worker-owned facts (Git anchor, dirty state, metadata, and last observed
harness command). It never invents task intent. goal and done_when were
removed from continuity.Handoff; pickup must obtain task scope and success
criteria from immutable TASK.md.
continuity.Handoff.Validate is the shared semantic gate for both release
and pickup. Strict decoding rejects removed/unknown fields, and validation
rejects circular handoff actions, commands that point at report/handoff
files, oversize or Markdown-smuggled authored list items, and malformed dead
ends. A failed release remains refused and is retried by the existing
rotation loop; a bad artifact that somehow reaches CAS is also refused at
pickup. The fuzzy “empty dead ends after a non-trivial later rotation” signal
is intentionally not yet enforced: no reliable diff-size/rotation-index
evidence is available at this validation boundary, so inventing a hard rule
would create false refusals.
Verified after the change with go test ./..., go build ./..., go vet ./..., and git diff --check.