Files
orchestra/BURNIN.md
T
kami cb80dd1952 Record run 20: the mismatch and replan ladder holds, and F66
Both halves proven live. A human_decision mismatch stops the task, the
blocker packet states the contradiction, and a Gitea reply resumes the same
plan in 55 seconds with no reseal. A replan reopens into plan while the old
plan stays accepted with its progress, and sealing the replacement moves
plan_ref, retains the old one in history, stops the old progress counting,
and hands the successor the replacement whole.

F66 recorded: the planning session a replan convenes is told nothing about
the contradiction it exists to fix, though all of it is durable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:39:04 +04:00

129 KiB
Raw Blame History

Burn-in: live conformance, not more architecture

Written 2026-08-26. The workflow is feature-complete enough to exercise. What remains is empirical: run the same real task shape through each harness and classify what breaks. Isolated tests are insufficient by design here, and only the live owner path establishes conformance.

Do not add workflow features while this is running. Findings decide the next implementation work.

Burn-in build identity

77a2b323fabcf080d7542061ae2d7b3c34eef5b7

Both halves must report exactly this revision before a task is created. Neither needs a credential now: the coordinator prints it in docker logs orchestra-api and the worker in journalctl -u orchestra-worker. The same object is at GET /v1/admin/diagnostics and GET /v1/federation/workers behind the operator login.

Build both with deploy/build.sh <outdir>, which refuses a dirty tree. Later documentation-only commits do not change this identity, so a rebuild either passes this revision explicitly or accepts the new one and redeploys both halves. Never one half.

Deployment state, 2026-08-26 18:35

Half State
Coordinator (homesrv) Deployed at 6f9300b. Rebuilt with --build-arg BUILD_REVISION, recreated, /readyz ready, self-reporting the revision in its log
Worker (workpc) Staged, not installed. ~/orchestra-deploy/orchestra-worker sha256 2b1c43071eaf6b5c15b110de39f204038a9629897e0ce3dacf45be96a6e6529e. Installed binary is still the 2026-07-30 build

Two operator steps remain, both needing root:

sudo install -m 0755 /home/kami/orchestra-deploy/orchestra-worker /usr/local/bin/orchestra-worker
sudo systemctl restart orchestra-worker
journalctl -u orchestra-worker -n 5 --no-pager   # must print revision 6f9300b...

A third blocker, found while preparing the pane check:

herdr is not running on workpc. herdr status server reports not running, the socket at /home/kami/.config/herdr/herdr.sock refuses connections, and its log stops at 2026-07-30. workpc-opencode therefore cannot start a pane, even with the worker installed. The worker logs serving harness workpc-opencode (opencode) on herdr backend at startup without touching the socket, so that line is not evidence the backend is reachable. herdr is an interactive terminal workspace manager: running herdr in a terminal launches or attaches to the persistent session and starts the server. Confirm with herdr status server before creating a task.

Then scrub the pane environment before letting an agent run.

Where the pane environment actually comes from

The two workpc harnesses inherit different environments, so one scrub does not cover both.

  • workpc-opencode (herdr backend). The pane is created by the herdr daemon, which is a separate long-running process. It inherits herdr's environment, not the worker's. Scrubbing worker.env does nothing here. Whatever environment herdr is started with is what every opencode pane gets.
  • workpc-claude (tmux backend). TmuxBackend.StartAgent runs tmux new-session through exec.CommandContext with no Env set (internal/herdr/tmux.go). If the tmux server is not already up, the worker starts it, and that server inherits the worker's full environment. Every claude pane then inherits it too.

This exposes a conflict the scrub alone cannot resolve. The worker legitimately needs ORCHESTRA_WORKER_TOKEN (or ORCHESTRA_WORKER_TOKEN_<ID>) and ORCHESTRA_FEDERATION_ADMIT_TOKEN, and deleting them breaks the worker. Keeping them means the tmux path hands them to the agent. Closing it needs either a filtered cmd.Env when the backend spawns a pane, or a tmux server started separately with a clean environment. Not on flow 1's path, because flow 1 is opencode only. Close it before step 6.

Baseline: one harness, one path

workpc-opencode only. Codex is a coverage gap, not a prerequisite, and no worker runs on homesrv. Prove one real path first:

real vikunja task
→ workpc-opencode
→ frame/research/plan
→ trajectory gate if configured
→ implement
→ independent review
→ task pr
→ human merge
→ completed

Then raise difficulty in this order:

1. opencode boring success
2. opencode mid-session human correction
3. opencode forced rotation
4. opencode pr rejection -> fix -> merge
5. opencode reconcile outage/recovery
6. representative cases on claude
7. add the codex adapter/config
8. cross-harness rotation: opencode -> claude/codex

Herdr transports, so nobody misdiagnoses again

No entry in the live config.jsonc sets backend or address, so all six resolve to <machine>:9245 (registry.defaultHerdrPort). Both ports are closed. That says nothing about the workpc harnesses: they are worker-owned, and the coordinator logs worker-owned on workpc; coordinator probe skipped for each. The worker reaches workpc-claude over a tmux socket and workpc-opencode over /home/kami/.config/herdr/herdr.sock. Do not diagnose harness availability from a TCP probe of 9245.

Evidence to record per run

One row per run. The launch instruction is now dumped to <worktree>/.orchestra/launch.md (herdr.LaunchContextFile) at every launch, local and federated, so the context is auditable without reading pane scrollback.

harness            (claude | codex | opencode)
task id
lease epoch        (every epoch, if the task rotates)
session ids        (pane ids per epoch)
human decision ids
launch context     (sha256 of .orchestra/launch.md, per launch)
handoff ref
git sha            (at every boundary: launch, each handoff, review, submit, merge)
review ref
submission ref
pr id
completion receipt

The five flows

  1. Boring success. task → research → plan → implement → review → pr → merge.
  2. Mid-session correction. Agent is doing A, the human says B, the next verified boundary delivers B, and A becomes history rather than a competing instruction.
  3. Rotation. Agent A hands off, agent B resumes. Switch harnesses across the boundary if both are up.
  4. Human rejection. Submit sha A, comment on the pull request, task reopens, fix at sha B, a fresh review, the same pull request, merge.
  5. Failure path. Human source goes down, the reconcile streak escalates, the session hands off, the successor lease is refused, the source recovers, and a corrected successor starts.

What to inspect by hand

For every launch, read .orchestra/launch.md and ask only:

does this agent know
- what the task actually wants?
- what was most recently decided?
- what phase it is in?
- what earlier material is merely historical?
- what to do next?

Then compare that against what the model did.

Classify before fixing

Every failure gets exactly one label before any code is written:

authority bug            the wrong thing outranked the right thing
context-selection bug    the renderer showed or hid the wrong material
lifecycle bug            state, lease, phase or event handling is wrong
adapter/harness bug       the pane, occupancy, boundary or prompt path is wrong
model-following failure  the context was right and the model ignored it
operator-policy gap      credentials, deployment or configuration

This matters because the cheap response to every failure is another prompt rule, and prompt rules accumulated to compensate for lifecycle or adapter bugs are how the enforcement boundary rots. A model-following failure is the only class a prompt change should ever answer.

Pane credential cleanup (operator, in parallel)

The agent pane inherits the worker's environment. On workpc the worker is orchestra-worker.service, User=kami, EnvironmentFile=/etc/orchestra/worker.env (mode 0600, root-owned, deliberately unreadable here). Remove from that file, and from anything else the pane inherits:

gitea mutation token
vikunja mutation credentials
orchestra operator / system token

Retain only ORCHESTRA_AGENT_TOKEN and credentials a specific task genuinely needs. If an agent needs a forge operation, it asks Orchestra to perform it.

Then verify from inside a real pane:

git push                 # must fail unless Orchestra supplied the auth
curl .../v1/tasks/<id>/submission   # must be 403 for the agent surface
tea pr create            # must have no usable mutation credential

Note what is not enforced by code: nothing in this repo scrubs the pane environment, and a fake git earlier in $PATH is not enforcement because /usr/bin/git bypasses it. Credential isolation is the enforcement. Confinement of network, filesystem and destructive commands belongs to whatever launches the process (herdr, a container, systemd, bubblewrap), not to Orchestra, which supplies identity and policy inputs.

Exit criterion

Roughly 10 to 20 real tasks, with the five flows covered on each harness that is actually up. Then read the classified failures and decide the next implementation unit from them.

Live findings, 2026-08-26 21:35

Both halves are paired at 6f9300b, and herdr is up (0.7.5, protocol 17). What remains before flow 1, and what the in-pane credential checks will really show.

Flow 1 has no repository to run in

workpc-opencode declares exactly one project, test-e2e, pointing at /tmp/test-e2e and /tmp/test-e2e-worktrees. Neither path exists on workpc after today's reboot, and /var/lib/orchestra/repos does not exist there at all. Nothing can be leased to this harness until it has a project backed by a durable local repository.

The only configured task source is Gitea kami/correx at https://gitea.kvmx.ru (ORCHESTRA_GITEA_URL/OWNER/REPO), so a real task means a correx issue. correx already has machine_affinity: ["homesrv","workpc"] in the coordinator's config.jsonc. What is missing is a workpc-local entry:

// /etc/orchestra/worker-projects.json, on workpc
{
  "correx": {
    "repo": "/home/kami/orchestra/repos/correx.git",
    "worktree_root": "/home/kami/orchestra/worktrees/correx",
    "remote": "origin"
  }
}

Not /tmp. The burn-in outlives a reboot.

There is no vikunja ingest

This repo has no Vikunja provider. /readyz reports gitea and jsonl. A "real vikunja task" cannot enter Orchestra today: it arrives as a Gitea issue or through the JSONL watcher.

git push from a pane will succeed, and no Orchestra setting stops it

Corrected 21:45. The mechanism is SSH, not HTTPS. HTTPS has no stored credential on workpc, where git push over https:// fails with could not read Username. Pushing works over ssh://git@gitea.kvmx.ru:2222 using kami's default RSA identity, which Gitea lists as the key named workpc. It carries no passphrase, so no agent is needed.

That identity is what lets the worker push, and panes run as the same user with the same home directory, so an agent inherits it. worker.env is irrelevant to this. Real isolation needs panes under a different unix user. Expect git push --dry-run to succeed from a pane, and record it as an operator-policy gap rather than a code defect.

The worker inherits ambient Git credentials by design: git() in cmd/orchestra-worker/main.go runs exec.CommandContext with no environment of its own. There is no separate credential path for the worker to hold something the pane does not.

The agent surface is currently unauthenticated

ORCHESTRA_AGENT_TOKEN is unset on the coordinator, and an unset surface token means the middleware performs no check for that surface. Probed live:

POST /v1/tasks/<id>/decision-request  -H 'X-Orchestra-Surface: agent'  -> 404 (reached the handler)
POST /v1/tasks/<id>/phase             -H 'X-Orchestra-Surface: agent'  -> 403 (refused)

The capability boundary holds. Authentication does not. Set ORCHESTRA_AGENT_TOKEN in the coordinator .env before the burn-in, and note the same is true of ORCHESTRA_MCP_TOKEN and ORCHESTRA_MAVEN_TOKEN, both unset.

Two more unset settings that change burn-in behaviour

  • ORCHESTRA_FEDERATION_ADMIT_TOKEN is unset, so any caller may register a new worker identity. Existing identities stay protected, because registering an existing id with a different token is refused.
  • ORCHESTRA_REVIEW_ACTORS is unset, which human.Trust reads as "anyone not explicitly ignored". Flow 4 will accept a task-moving comment from any Gitea actor. Set it to kami.

Flow 1 setup, 2026-08-26 21:45

Target: kami/test-e2e on Gitea, a throwaway repo, rather than correx.

Done:

  • Ingest switched. ORCHESTRA_GITEA_REPO=test-e2e in the coordinator .env (previous file kept as .env.pre-burnin-20260826), container recreated, still reporting 6f9300b. The project id must equal the repo name, because main.go sets Project: os.Getenv("ORCHESTRA_GITEA_REPO"). test-e2e already exists in config.jsonc with machine_affinity: ["workpc"].
  • Durable repo on workpc. Bare clone at /home/kami/orchestra/repos/test-e2e.git, worktree root /home/kami/orchestra/worktrees/test-e2e. Origin points at ssh://git@gitea.kvmx.ru:2222/kami/test-e2e.git, and git push --dry-run reports Everything up-to-date. The old /tmp paths did not survive the reboot and must not come back.

Remaining, needs root:

// /etc/orchestra/worker-projects.json
{
  "test-e2e": {
    "repo": "/home/kami/orchestra/repos/test-e2e.git",
    "worktree_root": "/home/kami/orchestra/worktrees/test-e2e",
    "remote": "origin"
  }
}

Then sudo systemctl restart orchestra-worker.

Store state, checked before the first run: 30 tasks, all test-e2e, with 23 blocked, 6 completed and 1 failed. All are July leftovers and none holds a lease. The coordinator has run ResumeAnsweredBlockers every second for hours without resuming any of them, so they are inert rather than merely quiet. The July stuck task 06FT6CKD9Y98AZRX6X8K3QXFZG is now failed.

A stale branch orchestra/scratch/oc-06ftgkjadcd2hwjn2zwjen90q4 exists on the remote from an earlier run. Harmless, but it is not from this burn-in.

Run 1: task 06G3YR34117MAYT6KEAC9RJHD0, 2026-08-26

Evidence ledger:

harness         workpc-opencode (herdr backend)
task            06G3YR34117MAYT6KEAC9RJHD0
source          gitea:test-e2e/1  "Add a --version flag to the healthcheck script"
attempts        3 leases: two nacked, third launched
pane            wN:p1, agent oc-06g3yr34117mayt6keac9rjhd0
agent session   ses_fc096de31ffelZeMngQEyBTxfh
worktree        /tmp/test-e2e-worktrees/06G3YR34117MAYT6KEAC9RJHD0
branch          orchestra/06G3YR34117MAYT6KEAC9RJHD0
head at launch  5e6c4783d4213a934dc486160b888b6014bc2d03
launch context  .orchestra/launch.md, 2138 bytes
phase           frame
decisions       none

What worked

  • The launch context is right. Authority order, the ambiguity ladder, the phase brief with frame: ... Do not change code, Current human decisions: None recorded, and a verified git state with worktree, branch and head.
  • The agent respected the phase. agent_status: done, no code changes, only Orchestra's own scratch directory in the tree.
  • A bad launch nacked cleanly. The first attempt failed on the base checkout and the task went back to queued with lifecycle_phase: launch_nacked and attempt: 1. No orphan pane, no stuck lease.

Fixed during the run

  • F1, authority bug, 2753a8d. Every federated launch died with effective intent: federation: 401 Unauthorized: unauthorized surface. GET /v1/tasks/<id>/intent exists for workers, and the authz worker-path exemption never included it. Twenty test packages passed throughout.
  • F2, authority bug, 09e572f. The rendered goal was the issue title alone and acceptance read Not stated., because Gitea.event parsed the issue body and dropped it from the TaskCreated payload. The store already read description. Every Gitea-sourced task so far ran on its title.
  • F3, self-inflicted, a0209a2. The launch dump left .orchestra/ untracked in a worktree whose own context said uncommitted changes: false. It would have polluted the gate, the review diff and the agent's git status.

Open, classified

  • F4, adapter/harness. rotation ... activity degraded: activity unknown: adapter: resolve session file: adapter: harness "opencode" has no session-file resolver. Thrash and activity triggers are permanently degraded on opencode. Observable in the worker's last_error, non-fatal.
  • F5, lifecycle. The router never leased this task. Every gate checks out by hand: worker online: true, herdr_status: reachable, fresh checked_at, supported_projects: ["test-e2e"], quota empty, no active leases, capability empty. A direct POST /v1/tasks/<id>/lease succeeded instantly. The refusal is upstream of Store.Lease and silent by construction (internal/router/router.go:197). All three runs were leased by hand.
  • F6, observability. Worker health reports active_task: null while the store shows the task leased and herdr shows a live opencode agent in the task's worktree.
  • F7, operator policy. ORCHESTRA_TUI_TOKEN is unset, and an unset surface token means no authentication for that surface. That is how the manual leases above were issued, unauthenticated, from another machine. The TUI surface is FullControl, so this is the whole control plane, not just the two agent request endpoints. Set the token.
  • F8, correctness. human.Reconciler.Reconcile iterates every configured source for every task, so the three queued correx tasks have their external ids looked up in kami/test-e2e. A source should only reconcile the tasks that came from it. Likely why those three never lease.

Fix pass before run 2, 2026-08-26 23:50

Burn-in identity is now 77a2b32. The coordinator is deployed at it. The worker is staged at it, sha256 2a850f2d1102d390..., still needing root to install.

Closed

  • F7, security. authz.RequireCredentials refuses startup when a full-control surface has no token, rather than logging it. ORCHESTRA_TUI_TOKEN is set in the coordinator .env. Verified live: an unauthenticated POST on the TUI surface now returns 401. Web is exempt because Sessions makes its login mandatory. ORCHESTRA_MCP_TOKEN, ORCHESTRA_MAVEN_TOKEN and ORCHESTRA_AGENT_TOKEN remain unset, so those surfaces are still unauthenticated for reads and for their three request endpoints. Bounded by capability, worth closing, not startup-fatal.
  • F5, lifecycle. Every eligibility gate now records a router.Rejection, exposed at GET /v1/router/health and reset per pass. No gate was weakened. The live output immediately explained the three stuck correx tasks: worker has not declared project correx. A queued task in retry backoff was skipped before the candidate loop and recorded nothing at all, which is the shape that hid the original case; it now reports retry backoff until <time>.
  • F8, correctness. Reconciliation is bound to task.Source, the provider:project identity the ingest stamped. A source that cannot prove it owns the task is skipped, and a task with no matching source reconciles to nothing and still launches. The integration fixture had encoded the bug: it ingested from jsonl and reconciled from gitea.
  • F11, outage. The API entered a restart loop exiting with invalid event: until_ns required. ValidateEvent compared until_ns against time.Now() for TaskLeased and TaskLeaseRenewed, so a lease event that was valid when written failed validation once it expired. store.Open replays the tail after the snapshot and log.Fatals on the first invalid event, so the coordinator refused its own history. Validation of a durable event is now time-independent. Latent since the field existed: it needed a renewal in the post-snapshot tail plus a restart after that renewal expired.

Also found

  • F9, lifecycle. An operator cannot release a leased task through POST /v1/tasks/<id>/release without knowing its harness_id and lease_epoch, because Store.Append fences lifecycle events on a leased task and the endpoint passes the request body through unchanged. Correct that only the owner releases, but there is no operator escape hatch.
  • F10, hygiene. A coordinator-side release under a live worker leaves the worker renewing a lease it no longer holds. Combined with F11 that produced a per-second invalid-event log line.

Shared checkout

Another session is working on the auth and frontend layers in this same checkout. Two consequences, both handled: deploy/build.sh and the container image now build in a detached worktree of the revision they stamp, so no uncommitted work is compiled into a stamped binary and no in-progress edit blocks a deploy. Commits from this session name their paths rather than using git add -A. The first commit, 7f12c7f, predates that discipline and swept in whatever was uncommitted at the time, including web/src and internal/authn.

Run 1 is closed as a diagnostic

Task 06G3YR34117MAYT6KEAC9RJHD0 was released with reason abandoned diagnostic run, supplying the lease fence by hand per F9. It sits at attempt: 3 and the router will fail it on its next pass. It is not a conformance run: it needed three manual leases and its authority was built before the issue-body fix.

Run 2 preconditions

worker installed at 77a2b32 and restarted
coordinator at 77a2b32                     done
F7 closed                                  done
F5 exposed                                 done
F8 fixed                                   done
fresh issue, no manual leasing

Runs 2 and 3, 2026-08-27

Written from live evidence. Neither run was a conformance pass, and both were worth more than one: run 2 found three blocking bugs behind each other, and run 3 found two more plus the reason six isolated probes disagreed with production.

AUDIT.md is uncommitted and owned by another session, so the burn-in ledger lives here.

Fixed, with the revision each landed in

  • F12, lifecycle, 0d67af9. Store.QuotaSince reported an empty window as unknown, QuotaAvailability fails closed on unknown, and every herdr in config.jsonc declares a quota limit. The only producer of a receipt is a completed lease, so nothing could ever be leased. An empty window is now observable zero. A receipt that declares its own consumption unknown still fails closed. Verified live: run 2 leased three seconds after the fixed coordinator started.
  • F13, observability, 0d67af9. federatedAvailability restated a quota refusal as worker health, so router health said stale heartbeat against a heartbeat one second old. Gates name themselves through router.ReasonedAvailability. Verified live.
  • F14, authority, a5d361b. Gitea ingest never set acceptance. One recognized heading, bullets and checkboxes until the next heading, order preserved, section removed from the description. Verified live on run 3: five ordered items, prose above the heading kept as the description.
  • F15, adapter, 1fd82f8. Two bugs. TmuxBackend.Prompt wrote the whole instruction with send-keys -l, Claude Code coalesced it into a paste, and the Enter was absorbed. And TaskLaunchAcknowledged meant "Prompt returned nil", not "the harness accepted it". Launch transport became a backend property, and ConfirmLaunch began polling for proof. The detection works. The fix did not: see F17.
  • F16, lifecycle, 1888d42. renewLeases renewed whenever a session existed and PaneCapture succeeded, so a pane that opened and never started renewed forever. This is why the July task stayed orphaned: F15 explains why nothing started, F16 why the lease never let go. Renewal now needs the agent busy, or the pane capture to differ from the hash recorded at the previous renewal. lease.ProgressSHA carries that hash.
  • F17, adapter, f54fb00. pendingInput scanned every line beginning with the prompt marker, but queued and already-accepted input renders with the same prefix. Only the editor owning the pane cursor is unsubmitted, so inputState reads #{cursor_y} and captures screen rows without -J, which would invalidate the row index. On that footing ConfirmLaunch became an active submit protocol: resend Enter while the live editor still holds exactly what was submitted, at most three times, no closer than two poll intervals, then observe until the deadline. Queued input confirms rather than fails. Evidence records confirmation, submit_attempts and both timestamps.
  • F19, lifecycle, edff021. A task that reached the router's MaxAttempts was permanently terminal. TaskReleased only increments Attempt, TaskCorrected could not touch it, and no HTTP route emitted a correction. POST /v1/tasks/{id}/retry requires the task to be failed, unleased, and failed with reason: retry_limit, then appends one TaskCorrected naming that failure with state: queued and attempt: 0. Identity, goal, acceptance, decisions, phase and artifact refs all survive, and the original failures stay in the log. operation_id is required and makes it idempotent. Deliberately not a generic correction endpoint.

The finding that mattered most

Run 3 failed three times with prompt_not_submitted, and six isolated probes of the same code path could not reproduce it: fresh session, untrusted directory with the trust dialog, launch.md present before startup, 100ms readiness polling, env -i with only the worker's systemd variables, and the real StartAgent/Prompt/ConfirmLaunch against a byte-identical git worktree. All six submitted on the first Enter.

F17 turned that disagreement into data instead of a theory. The first live launch after it deployed:

launch 06G44JZB80MZBEY97196EZN8EC confirmed: confirmation=editor_cleared
submit_attempts=2 first_submit_at=2026-08-27T09:12:00.801855937Z
confirmed_at=2026-08-27T09:12:01.315587139Z

Production needs the second Enter. Isolated probes need one. The submit is not deterministic, which is exactly what F15's ConfirmLaunch comment asserted it was.

Still open

  • F18, observability. A queued launch used to be misread as unsubmitted. F17 fixes the predicate, but nothing else that parses pane text distinguishes the active editor from history, so the same class of error can recur wherever PaneCapture output is matched.
  • F9 is unchanged by F19. An operator still cannot release a lease someone else owns without supplying that owner's harness_id and lease_epoch, which means reading them out of the event log first. F19 recovers a terminal task, which is a different escape hatch.
  • F6, observability. Worker health reported active_task: null while the store showed the task leased. Run 2 showed this was partly honest, because the worker really had no working agent. Recheck now that launches confirm.
  • F4, adapter quality. harness "opencode" has no session-file resolver, so activity and thrash triggers are degraded on opencode.
  • F10, hygiene. A coordinator-side release under a live worker leaves the worker renewing a lease it no longer holds.
  • Unset gated tokens: ORCHESTRA_MCP_TOKEN, ORCHESTRA_MAVEN_TOKEN, ORCHESTRA_AGENT_TOKEN.
  • gofmt -l fails on internal/provider/provider.go, internal/router/router.go and internal/webui/webui.go at edff021. None were touched by these runs. internal/router/router.go picked it up in 0d67af9. go vet passes, which is why nobody noticed.

Operator actions taken by hand, and why

  • Task 06G3ZCZWJ3QHF992ZMDGSJ0PYG, run 2, was blocked with block_reason: operator_block rather than released. A released task returns to queued, and the router leases queued tasks, so releasing it would have put it in competition with run 3.
  • The first block attempt returned task version conflict. Store.validateTransition fences every lifecycle event on a leased task, so the payload needs harness_id and lease_epoch even though the HTTP handler does not ask for them. That is F9 in practice.
  • Task 06G44JZB80MZBEY97196EZN8EC, run 3, was recovered with F19's retry rather than by filing a fourth issue. That keeps kami/test-e2e#3 served by its own task and proves terminal recovery in the same run.

Deployment boundary

Both halves report edff021, built from a detached worktree of that revision. Worker sha256 68ac265455acb0e007bfb0e898a91b317cd7289d7f2bf5da6df1224e1d6706a5 at /usr/local/bin/orchestra-worker. Coordinator image built 13:08:30 +0400.

Run 3: autonomous expiry and relaunch, 2026-08-27 11:02 UTC

Task 06G44JZB80MZBEY97196EZN8EC, issue kami/test-e2e#3. Observed live, no operator action in the window. The stranded epoch was 06G44ZBZX4YH6PRN7Y4ZH8GG3W.

seq at (UTC) event epoch
358 10:32:09 TaskLeaseRenewed v15 06G44ZBZX4YH6PRN7Y4ZH8GG3W
359 11:02:16 TaskReleased v16, reason: lease_expired 06G44ZBZX4YH6PRN7Y4ZH8GG3W
360 11:03:17 TaskLeased v17 06G45RVKAW20H1D22NHBRYKMN8
361 11:03:22 TaskLaunchAcknowledged v18, lifecycle_phase: started 06G45RVKAW20H1D22NHBRYKMN8

Expiry to acknowledged launch: 66 seconds, autonomous. Every event from seq 353 onward carries surface: system. The last tui event is the TaskCorrected at 09:11:55, before the window.

Launch receipt, worker journal, 15:03:22 +04:

launch 06G44JZB80MZBEY97196EZN8EC confirmed: confirmation=editor_cleared submit_attempts=2 first_submit_at=2026-08-27T11:03:22.203859355Z confirmed_at=2026-08-27T11:03:22.717657436Z

F16 is partial, not closed

The hard predicate passed: no renewal carried the stranded epoch after 10:45:26. The run did not exercise the progress comparison. Worker health records why the renewal was skipped:

"last_error": "validate lease 06G44JZB80MZBEY97196EZN8EC: tmux capture-pane -p -J -S -200 -t =orchestra-06g44jzb80mzbey97196ezn8ec-e53607ea:1.0: no server running on /tmp/tmux-1000/orchestra: exit status 1",
"error_at": "2026-08-27T11:02:16.213318366Z"

That is the paneProgress error path at cmd/orchestra-worker/main.go:973, not the default branch at line 992. The tmux server was gone, so no pane text could be hashed.

  • Proven: dead or missing pane, no renewal, lease expires, autonomous re-lease and relaunch.
  • Unproven: live pane with an idle agent and unchanged output, renewal refused.

Epoch reset, stated precisely

The new epoch and its acknowledged launch establish the reset structurally. leases in /var/lib/orchestra/worker-state/workpc-claude.json holds only {epoch, version, until}, with progress_sha absent under omitempty. No runtime value of ProgressSHA, UsageBaseline or PickupAcknowledged was observed.

F6 closed

Worker health reports active_task_id: 06G44JZB80MZBEY97196EZN8EC with active_pane_id set, against a leased task.

F18 sharpened, non-blocking

recordError (cmd/orchestra-worker/main.go:70) keeps one slot, so the earlier renewal decision near 10:52 was overwritten and cannot be read. One last_error slot cannot preserve a causal sequence. Later replacement: a bounded recent-error ring or event-backed observations, roughly the last 8 {at, class, message} entries. Do not land this during a live run.

Ledger after this checkpoint

F6   closed
F14  closed
F15  closed by detection, transport fix pending live proof
F16  partial: missing-pane branch proven, static-live-pane branch unproven
F17  fixed, isolated live proof
F18  open observability
F19  fixed
F20  awaiting first post-frame confirmed input

Next decisive checkpoint, F20

frame completes
→ Orchestra emits the next input
→ worker journal must contain: input to <pane> confirmed: confirmation=<kind> submit_attempts=<n>
→ only then may the agent continue

Receipt appears and the model proceeds: let the run continue through phase transitions. Input lands with no receipt: F20 fails. No input attempted after frame: a different lifecycle or phase-advance bug.

Run 3 conformance result, 2026-08-27 12:00 UTC

run 3 conformance result: failed
cause: no autonomous phase-advance path

F16 closed

missing-pane branch: proven
live-pane idle + unchanged output branch: proven
external editor input ignored as progress: proven

Second branch evidence, worker health at 11:53:46 with the pane alive and herdr_status: reachable:

"last_error": "lease 06G44JZB80MZBEY97196EZN8EC not renewed: agent status idle and pane unchanged since the last renewal",
"error_at": "2026-08-27T11:53:46.195553513Z"

That is the default branch at cmd/orchestra-worker/main.go:992.

F21, lifecycle: the phase brief promises a protocol that does not exist

.orchestra/launch.md:62 tells the agent Orchestra decides when this phase ends. Ask for a phase change, do not declare one. The agent complied and printed Nothing blocks. Ready for a phase change to implement.

phase brief:  "ask for a phase change"
agent:        asks
worker:       has no representation of that request
coordinator:  AdvanceWorkPhase exists, but nothing invokes it autonomously

operations.AdvanceWorkPhase is reachable only from the HTTP handler at cmd/orchestra/main.go:883 and from internal/operations/review.go:101. The worker's only post-frame send is a decision notice at cmd/orchestra-worker/main.go:1639, gated on len(answer.Decisions) == 0 returning early.

The fix is a bounded agent intent, not pane-text matching:

type PhaseAdvanceRequest struct {
    From WorkPhase
    To   WorkPhase
}
verified turn boundary
→ obtain bounded phase intent from harness
→ validate requested transition
→ submit to coordinator
→ operations.AdvanceWorkPhase
→ if transition requires sealed artifact:
     collect/seal artifact first
→ rotate/start successor if phase policy requires fresh context

frame needs no artifact. research and plan take the same path but must seal their artifact before AdvanceWorkPhase accepts them.

F22, lifecycle: nothing reacts to WorkPhaseChanged at runtime

t.WorkPhase is read only when a launch context is built, at cmd/orchestra-worker/main.go:369 and internal/orchestrator/orchestrator.go:1218. No code path rotates, relaunches or notifies a live session when the phase changes. A manual advance therefore takes effect only at the next launch, and leaves the current idle pane idle.

F23, lifecycle: a decision cannot exist before a submission

answer.Decisions is the only input sendPrompt ever carries post-frame. Decisions come from operations.ReflectSubmission, and human.PullRequestState.FeedbackAfter returns input only strictly after a submission. A task still in frame has no submission, so no decision can be recorded for it.

Consequence: F20 cannot be exercised on this task in its current state. A manual phase advance does not produce a post-frame input, because nothing reacts to the phase change and no decision can exist yet.

Observation kept separate from F20

go ahead and implement it appeared in the editor again on attempt 2, cursor at cursor_x=2, never submitted. The agent transcript at ~/.claude/projects/-tmp-test-e2e-worktrees-06G44JZB80MZBEY97196EZN8EC/97b30e4d-90a7-49bc-a9b8-7d9e450831e4.jsonl holds exactly one user message, the launch prompt at 11:55:52.236Z. No worker send and no receipt exist for it. Origin unknown, external to Orchestra. F20 does not absorb it.

Ledger after this checkpoint

F6   closed
F14  closed
F15  closed by detection, transport fix pending live proof
F16  closed, both branches live-proven
F17  fixed, isolated live proof
F18  open observability
F19  fixed
F20  blocked, not merely awaiting: see F23
F21  open lifecycle, no autonomous phase-advance path
F22  open lifecycle, no runtime reaction to WorkPhaseChanged
F23  open lifecycle, no decision path before a submission

F21, F22, F23 implemented, 2026-08-27

Run 3 was not shepherded further. Attempt 3 was left unspent: F16 is closed on both branches, and another idle phase would have proven nothing new.

F23 was already implemented, and the earlier entry was wrong

human.Reconciler.Reconcile imports issue comments from task.Source and records HumanDecisionRecorded with no submission involved. It is wired at two points in cmd/orchestra/main.go: Store.PreLease, and Coordinator.ReconcileHumanInput, which RemoteTurn calls at every verified turn boundary. provider.GiteaComments.FetchAfter reads the task's own issue.

The earlier F23 entry traced EventHumanDecisionRecorded through internal/operations only and concluded decisions required a submission. That was a scoping error in the search, not a gap in the code. ReflectSubmission is the pull-request path and stays narrow; it was never the general decision source.

Consequence for the record: F20 had a legitimate post-launch send path throughout run 3. A comment on kami/test-e2e#3 would have produced a decision, a DecisionNotice at the next boundary, and a receipt.

F23 is closed as already-implemented, with tests added for the boundary it turns on.

F21, the phase-request protocol

The agent asks with a bounded file. Prose is not a protocol, so nothing matches on pane text.

.orchestra/phase-request.json   {"from": "frame", "to": "research"}
.orchestra/research.json        sealed before leaving research
.orchestra/plan.json            sealed before leaving plan

At a verified turn boundary the worker validates what it can see locally: the phase the agent believes it is in, the legality of the transition, and the presence and decode of the artifact the phase must seal. It then calls POST /v1/federation/phase with the lease epoch and a derived operation id. The coordinator calls the existing operations.AdvanceWorkPhase.

  • internal/operations/workphase.go: RequestWorkPhase, ErrPhaseRequest. Fences on lease epoch, refuses a stale phase belief, refuses any target but the project's next phase, idempotent per operation id.
  • internal/federation/client.go: Client.AdvancePhase.
  • cmd/orchestra/main.go: the /v1/federation/phase route.
  • cmd/orchestra-worker/main.go: requestPhase, phaseArtifact, phaseRequestFile.
  • internal/agentctx/agentctx.go: phaseRequestBrief renders the protocol under the sentence that used to promise it.

The operation id is derived, not random: phase:<task>:<epoch>:<from>:<to>. A redelivery after a lost response carries the same id, so the coordinator returns the first event instead of advancing twice.

F22, a phase change ends that cognitive session

herdr.Session now carries Phase, the phase the session was launched to run. At a turn boundary a session whose task has moved on is rotated with reason phase_changed, which is added to the closed handoff-reason set in internal/herdr/adapter.go and given its own wording in RequestHandoffReason.

One comparison covers both cases: a change this worker requested, and one an operator made through POST /v1/tasks/{id}/phase. Both leave the same evidence, a session built for a phase that is no longer current.

F20 hole found and closed while implementing F22

CLIAdapter.prompt called backend.Prompt and returned. Every rotation and handoff prompt therefore went out unconfirmed, which is precisely the failure F20 exists to catch. RequestHandoffReason is one of its callers, so the phase rotation would have inherited it.

Fixed at the shared call site rather than per caller: prompt now routes through InputConfirmer and logs the same input to <pane> confirmed: receipt the worker logs.

Tests

go build ./..., go vet ./... and go test ./... all pass.

  • internal/operations/phase_request_test.go: full path with each artifact sealed, skipped phase refused, stale phase belief refused, stale epoch refused, missing operation id refused, redelivery idempotent, operation id recorded.
  • cmd/orchestra-worker/phase_test.go: request accepted then session rotates, external phase change rotates, unsealed artifact refused locally, malformed artifact refused locally, sealed artifact travels with the request, refused request retried, decision notice stays undelivered until confirmed.
  • internal/integration/phase_protocol_test.go: a pre-submission issue comment steers a live leased session and is not re-sent once delivered; the phase-request path seals and fences at the coordinator.
  • internal/human/pullrequest_window_test.go: pull-request feedback ignores anything not strictly after the submission, and applies trust.
  • internal/agentctx/agentctx_test.go: the brief names the request file, the request shape, and the artifact to seal.

Ledger

F6   closed
F14  closed
F15  closed by detection, transport fix pending live proof
F16  closed, both branches live-proven
F17  fixed, isolated live proof
F18  open observability, non-blocking
F19  fixed
F20  fixed, adapter hole closed; live proof pending run 4
F21  fixed, tests only
F22  fixed, tests only
F23  closed, already implemented; tests added

Run 4 starts from here. Both halves must be rebuilt and redeployed before it begins, and the deployment boundary recorded as usual.

Deployment boundary for run 4, 2026-08-27

Revision 1f5bf7e, both halves, built from a detached worktree of that commit.

Half Evidence
Coordinator, homesrv container docker logs orchestra-api, orchestra revision 1f5bf7e66e0c6dc8dc1db794ab91193c6c38e118 built 2026-08-27T16:35:37+04:00 dirty false, /readyz 200
Worker, workpc systemd pending operator install

Worker sha256 c188ec78f7b11bd0f3b72da2c3544c5c9477b4505816b30bd0f243f6fa8032da, staged at ~/orchestra-deploy/orchestra-worker.1f5bf7e.

Both worker registrations must report 1f5bf7e before run 4 starts.

F24, correctness, dormant on the current topology

CLIAdapter.LeasePrompt (internal/herdr/adapter.go:161) calls backend.Prompt and returns without confirming. It is the last Orchestra-owned editor write outside the delivery guarantee.

Dormant, not fixed: the coordinator-local harness path cannot execute here. No local herdr is eligible, and Coordinator.adapterFor refuses a session owned by a non-local herdr. Run 4 exercises the federated worker path, whose launch delivery is already confirmed at cmd/orchestra-worker/main.go:454.

The eventual fix:

CLIAdapter.LeasePrompt
→ backend.Prompt
→ ConfirmInput
→ only then acknowledge launch

The comment in place there argues that waiting would turn a long first turn into a false lease failure. It is obsolete: ConfirmInput proves submission, not completion of the first turn.

Required before claiming coordinator-local harness conformance. Not required before this federated burn-in, and changing the revision now would add churn run 4 cannot observe.

The y/n approval paths stay separate

internal/herdr/adapter.go:703 and cmd/orchestra-worker/main.go:1086 write to a y/n dialog, not an editor. They need a capture-revision-aware confirmation protocol of their own. The editor confirmer would report nonsense on them, so they are deliberately outside F20 and stay that way.

Run 3

Left running and untouched. Whatever it does from here is additional diagnostic evidence. Run 4 does not wait on it.

Run 4: failed conformance, 2026-08-27

Task 06G46P6KE25Y04VVF7VRZMHZ78, issue kami/test-e2e#4, revision 1f5bf7e.

run 4: failed conformance

cause:
f25, claude rotationTick bypassed federatedTurn

evidence:
phase-request.json existed
no WorkPhaseChanged followed
no boundary error was emitted

additional defects:
f26 phase brief advertised invalid shortcut
f27 phase refusal was not delivered to agent

What run 4 did prove

The front half of the chain is clean, with no operator lifecycle intervention.

seq at (UTC) event
370 13:11:29 TaskCreated v1, 7 acceptance criteria
371 13:11:30 TaskLeased v2, epoch 06G46P6Q4EAPF7DFDBFBTA9RJC
372 13:11:33 TaskLaunchAcknowledged v3

Issue to confirmed launch: 4 seconds. F14 passes, against run 1 where ingest set no acceptance at all. F15 and F17 pass again with confirmation=editor_cleared submit_attempts=1.

The agent wrote its request 25 seconds after launch:

/tmp/test-e2e-worktrees/06G46P6KE25Y04VVF7VRZMHZ78/.orchestra/phase-request.json
{"from": "frame", "to": "implement"}

The artifact half of F21 works. The reading half never ran.

F25, lifecycle: the turn boundary was unreachable on claude

rotationTick returned early for this harness before reaching the boundary, and federatedTurn has exactly one call site, below that return.

if w.harness == "claude" {
	if err := w.advanceClaudeContextReset(ctx, id, s); err != nil { ... }
	return
}

Introduced in 7f12c7f. Consequences on the harness both burn-in runs used: no phase request could ever be read, and every human decision recorded against a live claude session went undelivered.

This retracts an earlier claim in this ledger. F23's decisions do reach RemoteTurn, but on claude the worker never asked, so run 3 had no post-launch send path either. The statement that a comment on #3 would have produced a receipt was wrong.

Fixed: claude runs the common boundary after its context reset. It still skips the occupancy state machine, because it owns its own rollover. A turn boundary is not a rotation.

F26, authority: the brief advertised an invalid shortcut

The deployed brief rendered Legal values for "to" from here: research, implement, listing every domain-legal move. The project's path allows only research. The agent took the shortcut, reasonably.

Fixed: the brief names one target and states that a wrong one comes back with the right one. The project's path is Orchestra's to know.

F27, lifecycle: a refusal never reached the agent

A refused request only reached w.recordError. The agent would rewrite the same rejected file at every boundary with nothing telling it why, which is the silent-loop shape this codebase keeps producing.

Fixed: federation.StatusError makes a 409 classifiable. A refusal is delivered through sendPrompt, so it travels under the F20 guarantee, and the request file is cleared. A transport failure keeps the file and tells the agent nothing, because it is not an answer.

Ledger

F6   closed
F14  closed
F15  closed by detection, transport fix pending live proof
F16  closed, both branches live-proven
F17  fixed, isolated live proof
F18  open observability, non-blocking
F19  fixed
F20  fixed, live proof still pending
F21  artifact half live-proven, reading half pending
F22  fixed, live proof pending
F23  closed as already implemented, undeliverable on claude until F25
F24  open correctness, dormant on current topology
F25  fixed, tests only
F26  fixed, tests only
F27  fixed, tests only

F28, deployment: the execution runtime shares the worker's cgroup

Worker restart destroys agent sessions because the tmux execution runtime shares the worker service cgroup. tmux must be independently lifecycle-managed.

The worker spawns the server implicitly on its first tmux command, so the server and every pane land in orchestra-worker.service:

orchestra-worker.service
├── orchestra-worker
└── tmux server
    └── claude panes

KillMode does not fix this. Under mixed systemd still sends the final SIGKILL to whatever remains in the cgroup once the main process exits, and process only encodes accidental orphaning, which systemd itself discourages. An earlier draft of this entry proposed mixed and was wrong.

The split:

orchestra-worker.service
└── orchestra-worker

orchestra-tmux.service
└── tmux -L orchestra
    └── claude sessions

deploy/orchestra-tmux.service is new. deploy/orchestra-worker.service gains After=/Wants= on it, ordering only: a worker that finds the runtime missing must report that rather than be stopped by it. The unit carries an idle orchestra-runtime session so the server outlives its last agent pane.

Probed live on a throwaway socket: tmux -L <s> new-session -d leaves the server running under its own pid after the client exits, so Type=forking resolves a main pid. systemd-analyze verify passes.

User must match between the two units, because the socket lives under /tmp/tmux-$UID. The installed worker unit on workpc runs as kami while deploy/orchestra-worker.service still says orchestra. The staged copy at ~/orchestra-deploy/orchestra-tmux.service is set to kami to match reality.

What F28 retroactively explains

  • Run 3's no server running at 11:02:16 followed the 10:45:26 worker restart.
  • The same at 13:06:22.
  • Run 4's refusal could not be delivered at 13:32:16, seconds after the 13:31:51 restart.
  • F16's missing-pane branch has been firing on deployment, not on real execution-runtime loss. Its proof stands as written, but the trigger was self-inflicted.

F28 proof, to run after the split

1. start task and obtain live pane P
2. record pane/session identity
3. systemctl restart orchestra-worker
4. assert pane P still exists with identical agent identity
5. new worker starts
6. worker reconciles lease + pane P
7. no TaskReleased / new lease caused merely by worker restart
8. agent continues under the same lease epoch

Then separately, to restore the meaning of F16's missing-pane branch:

restart orchestra-tmux
→ pane disappears
→ F16 refuses renewal
→ lease expires and requeues

Run 4 diagnostic continuation, partial

The chain ran and failed only at the last step, at 13:32:16:

deliver phase refusal 06G46P6KE25Y04VVF7VRZMHZ78: tmux send-keys ... -l -- Orchestra refused your phase request: phase request refused: task 06G46P6KE25Y04VVF7VRZMHZ78 may only move to "research", not "implement"
...: no server running on /tmp/tmux-1000/orchestra: exit status 1

Live-proven by that one line: F25, the boundary ran on claude at all; F21's reading half; F26's premise, the coordinator naming the one valid target; F27, the 409 classified and routed to sendPrompt. The request file survived the failed send, which is the correct branch.

Not proven: the receipt itself, because F28 had already destroyed the pane.

F28 closed, and three defects it uncovered, 2026-08-28

The unit split is installed on workpc and F28 is proven live in both directions. Three new defects, F29, F30 and F31, were found while setting the proof up. None was visible in tests.

The tmux unit, as installed

The staged Type=forking draft was wrong in a way the earlier entry did not catch: without PIDFile=, systemctl show orchestra-tmux -p MainPID returned 0. Systemd had no main process to watch, so a crashed server would read as a clean exit and Restart=on-failure would never fire.

tmux -D runs the server in the foreground, which gives systemd the real pid. It also turns exit-empty off, so the synthetic orchestra-runtime session is no longer needed to hold the server open past its last agent pane. Nothing in the tree referenced that session.

[Service]
Type=simple
User=kami
ExecStart=/usr/bin/tmux -D -L orchestra
Restart=on-failure
RestartSec=1

Verified after install: MainPID=3073848, cgroup holds only the server, and the server stays alive with zero sessions.

deploy/orchestra-worker.service said User=orchestra while the installed unit ran as kami. Since the socket is /tmp/tmux-$UID, that mismatch would have pointed the worker at a different server. Corrected in fc4c29f.

F28 proof, live

Vehicle: disposable task 06G474FXXFW7V54RGF70ZS3SRG, forced onto workpc-claude, leased at 20:05:07Z under epoch 06G49MW2CHNVT01SHEKVYJ2170, pane orchestra-06g474fxxfw7v54rgf70zs3srg-59bf1ee9:1.0, pane pid 3012539.

Assertion Result
Pane survives worker restart 3 restarts, pid 3012539 every time
Same agent identity unchanged
Same lease epoch continues 06G49MW2CHNVT01SHEKVYJ2170
Worker reconciles lease and pane leases and sessions rehydrated each time
No TaskReleased from a restart event log empty after seq 385
No replacement TaskLeased same
Subsequent renewal succeeds 00:25:11 local, v6 → v7, progress_sha recorded

The event log is the load-bearing evidence. The only TaskReleased on this task is seq 382 at 20:04:06Z, from an earlier failed launch, well before the first restart at 20:08:33Z. The restarts appended nothing at all.

Forcing a renewal by shortening the worker's cached until does not work. The worker re-hydrates the authoritative deadline from the coordinator on startup, before any renewal tick runs. The cache is not a lever.

Forcing a task onto one harness

config.jsonc gave workpc-claude the capabilities ["code", "review"], and workpc-opencode ["code", "review", "opencode"]. No capability selected claude uniquely, so an operator test could not pin placement. claude was added to workpc-claude, mirroring the convention opencode already followed. Router rejections at /v1/router/health named the gate directly, which is what made this a two-minute diagnosis instead of an afternoon.

F29: the browser could not act on a leased task

validateTransition fences TaskReleased, TaskBlocked and TaskCompleted on a leased task against the live harness_id and lease_epoch. The UI action handler sent neither, so all three returned 409 on exactly the tasks the UI listed them as enabled for.

Found by trying to park run 4: eight block attempts, stable v8, all 409 task version conflict. The workaround was to block during the queued window, where t.Lease is nil and the fence does not apply.

Fixed in efd0a5e. The fence exists to reject a stale writer, not the operator, so the handler now carries the lease it just read. The version CAS on the append still rejects a racing write. A missing request body also wrote into a nil map, which is fixed alongside.

F30: an unpushed release pinned the worker's only slot

A release transaction that never reached anchor_pushed has no artifact. tx.Ref is empty and no successor can pick anything up. The event handler kept its session mapping alive anyway, so once the pane was gone the mapping was immortal. health() reports ActiveTask straight out of w.sessions, so the coordinator saw the harness as permanently busy and never leased to it again.

Live on workpc-claude, stuck at phase prepared behind this error:

adapter: invalid handoff answer: invalid handoff authored field: prose smuggled into list

It produced no log line for fifteen minutes. Freeing it needed hand surgery on /var/lib/orchestra/worker-state/workpc-claude.json with the worker stopped.

Fixed in 2dc90bd. The mapping is kept only while an anchor actually exists, and the transaction is dropped with it, since nothing can advance it.

Worth recording separately: the rejected handoff artifact came from an agent running the operator's direct-prose stop hook. That is a live interaction between an operator hook and the handoff contract, not a defect in either.

Ledger

F6   closed
F14  closed
F15  closed by detection, transport fix pending live proof
F16  closed, both branches live-proven
F17  fixed, isolated live proof
F18  open observability, non-blocking, cost diagnosis time twice this session
F19  fixed
F20  fixed, live receipt still pending
F21  reading and artifact halves live-proven; accepted transition pending
F22  fixed, live proof pending
F23  closed as already implemented
F24  open correctness, dormant on current topology
F25  fixed, live-proven
F26  fixed, live-proven
F27  fixed, live-proven
F28  CLOSED, live-proven in both directions
F29  fixed in efd0a5e, needs a coordinator rebuild
F30  fixed in 2dc90bd, needs a worker rebuild
F31  fixed in 6523002, needs a worker rebuild, BLOCKS every rotation

Deployment state

Both fixes are committed and unbuilt. The running coordinator and worker are still fbaaf79. Run 5 should start from a deployment carrying efd0a5e and 2dc90bd, or it will hit F30 again the first time a handoff artifact is rejected.

F28 runtime-death direction, live

systemctl stop orchestra-tmux at 00:39, restarted at 00:39:37 as MainPID 3073848. Every pane died with the old server.

20:45:07Z  renewal window opens, no TaskLeaseRenewed appended   F16 refused
20:55:07Z  lease deadline
20:55:50Z  state queued v8, lifecycle reclaimed, failure_class lease_expired

Runtime death is now distinct from worker deployment. F16's missing-pane branch fires on real execution loss, which is what it was written for.

The same window reproduced F30 a second time, on the deployed fbaaf79 worker that lacks 2dc90bd: session still pinned, release transaction stuck at prepared, same rejected handoff artifact.

F31: every rotation failed on a length check that named the wrong cause

Two different tasks, two different agents, one error:

adapter: invalid handoff answer: invalid handoff authored field: prose smuggled into list

parseHandoffAnswer builds Action by joining the agent's NEXT and WHY answers with " — ". Validate then held that join to maxAuthoredLine, 200 characters. The prompt asks for a sentence each and names no budget, so two ordinary sentences do not fit.

The message named a branch the answer cannot reach. parseHandoffAnswer splits on newlines and trims every field, so no authored field ever contains "\n#". Length was the only reachable cause, and the agent was never told what to shorten. It could not self-correct, so the same text failed on every retry.

This is why run 4 and the F28 disposable task both died at rotation, and why each left the stuck transaction behind that pinned the worker slot.

Fixed in 6523002: Action carries the budget of both lines, the error names the actual length and limit, and the prompt states the limit.

F31 blocks run 5 outright. Run 5's chain is four rotations.

Deployment of 93338b7, and F32, 2026-08-28

Both halves deployed from one revision, built from a throwaway worktree because the shared checkout still carries 20 uncommitted paths from another session.

coordinator  93338b72cec5facdb991d5dfa56eb9d3195749d0  image sha256:8b57491da293  readyz 200
worker       93338b72cec5facdb991d5dfa56eb9d3195749d0  sha256 168bf20724ec7e03bb5bd19320d4b698d31441d15ce3706d0498d1c85ebd044f
tmux runtime MainPID 3073848, untouched across the worker install

The worker install is itself further F28 evidence. A real deployment left the runtime and its cgroup alone.

F29, live-proven

Blocking the F28 disposable task while leased returned HTTP 200 and moved it to blocked v10. The same call returned 409 eight times before this deploy.

F30, live-proven, and the defect hiding behind it

The stuck releases entry cleared on the block, which is the fix working. The session did not clear. It was marked quarantined instead, because Kill returned an error for a pane that no longer exists.

F32: an empty tmux server answers differently

tmux has-session -t '=<gone>'   →  "no current target"   exit 1

hasSession recognised can't find session, no server running, no sessions and a socket-missing case. It did not recognise no current target, so a missing session read as a real error.

This is a side effect of dea56e4. Before the runtime became its own unit, the server exited with its last session and answered no server running, which was already handled. tmux -D keeps the server alive past its last pane, so this reply had never been produced before today.

Consequences: Kill fails for an already-dead pane, the quarantine never clears, and the worker's only session slot stays pinned. AgentStatus shares the helper and errored where it should report exited, which means F16's missing-pane branch was refusing renewal by error rather than by detection.

Fixed in 92adf04.

Ledger

F28  closed, live-proven in both directions
F29  fixed in efd0a5e, DEPLOYED, live-proven
F30  fixed in 2dc90bd, DEPLOYED, partially live-proven, rest blocked by F32
F31  fixed in 6523002, DEPLOYED, awaiting run 5's first rotation
F32  fixed in 92adf04, needs a rebuild, BLOCKS leasing on workpc-claude

Run 5, first attempt: the launch never went off, 2026-08-28

Filed as kami/test-e2e#5, ingested as 06G4A4F0TFXKZHJE48N05XN1HG, placed on workpc-claude by normal routing. Issue filed to confirmed launch: 44 seconds.

Then nothing moved for seven minutes. The pane held the launch text unsent:

 @.orchestra/launch.md is your complete Orchestra launch instruction. Read
  .orchestra/launch.md now and follow it.

0 tokens        ⏱️ 0m 0s        session ... - none

F33: an editor that never rendered confirmed the submit

first_submit_at  21:13:19.570302125Z
confirmed_at     21:13:19.577191672Z
confirmation     editor_cleared   submit_attempts=1

Seven milliseconds. ConfirmInput's first poll ran before the TUI rendered the pasted text, found an empty editor, fell through to the idle branch, and called that proof of submission.

The two launches that worked earlier took submit_attempts=2 over roughly 500ms. The difference between a good launch and this one was scheduling luck, which is why it had never been seen before.

The cost is worse than a failed launch. The coordinator believes the agent is working, so the lease renews against a pane that will never produce anything, and the failure only surfaces at expiry half an hour later.

Fixed in 32220d9. An empty editor counts as proof only after it has held the text, or after it has stayed empty past a settle window. Text seen and then gone still confirms immediately, and so do busy, blocked and queued. With the fix the failing case reaches the existing resubmit path instead: the text appears, is recognised as unsubmitted, and Enter is resent.

Recovery, deliberately not by hand

Pressing Enter in the pane would fix this run and prove nothing. The lease is left to expire and relaunch on its own, which is the autonomous path run 3 already proved. The worker carrying 32220d9 is deployed first, so the relaunch uses the fixed confirmation.

Ledger

F29  DEPLOYED, live-proven
F30  DEPLOYED, live-proven
F31  DEPLOYED, still awaiting a first rotation
F32  DEPLOYED, live-proven, slot reaped and freed
F33  fixed in 32220d9, needs a rebuild, BLOCKS every launch nondeterministically

Run 5, second attempt: two more layers, 2026-08-28

The stuck launch was recovered by hand, one Enter into the pane. That is an operator recovery of a keystroke Orchestra believed it had already delivered, not a lifecycle intervention, and F35 turns it into an action for next time.

F34, live-proven while being written

01:33:37  first renewal allowed, no baseline, lease extends 21:43 -> 22:03

The pane had not changed since 01:13 and held unsent text. The renewal gate exempts a lease with no baseline, which grants a full free period to exactly the case the gate exists to catch. Fixed in 92f32d6 by baselining the progress hash at launch.

F35: nothing could re-poke a pane

The UI offered grant_approval, deny_approval, handoff, release, block and complete. Orchestra can put text in an editor and be wrong about whether it landed, and the only recovery was to destroy the lease and wait out expiry.

resubmit presses Enter on text Orchestra itself submitted. No lifecycle change, so no event; fenced like an approval, on a live worker-owned pane at the capture revision the operator was looking at. In 92f32d6.

The gate, half cleared

01:36:35  phase request accepted: frame to research
01:36:35  acceptance delivered, confirmation=queued
01:36:35  phase changed: session rotating

Further than any previous run. work_phase reached research and the request artifact was consumed.

F36: the two halves of the handoff contract disagreed

before  adapter: invalid handoff answer: invalid handoff authored field: prose smuggled into list
after   adapter: upload handoff: invalid handoff meta

F31 cleared the parse, and the release then failed one stage later. handoffReason has emitted phase_changed since phase rotations landed. The validator's reasons list was never extended, so every phase rotation built a handoff it then refused.

Fixed in b9ca365. The test asserts the property, not the constant: every reason the adapter can produce must survive Validate, including its fallback.

Pattern worth naming

Six defects tonight, each hidden behind the one before it. F30 was reachable only once F29 let an operator act, F32 only once F30 tried to reap, F34 and F36 only once F33 and F31 let execution get that far. Tests passed throughout. The only thing that surfaced any of them was running the system.

F37: the handoff carried the write Orchestra asked for

One stage past F36:

adapter: upload handoff: invalid handoff command: must not point to a handoff or report

Handoff.Command is the last command observed in the pane. The handoff prompt tells the agent to write .orchestra-handoff-report.md and stop, so that write is almost always the last command there. .orchestra-handoff-report.md matches every alternative in circularCommand, so every rotation failed on Orchestra's own instruction.

Fixed in cb7782d. lastObservedCommand skips commands the validator would call circular. The rule is asked for through continuity.IsCircularCommand rather than restated, because restating a rule in two places is exactly what produced F36.

Run 5: the continuity chain, live and autonomous, 2026-08-28 01:47

The first successful phase rotation in this project's burn-in history.

handoff_ref           abe51f5c663f4d59bba2c98c0418554278ec2a9b75bfc504bd36757666e3948a
transaction_id        06G4A9VSBE5N5BF8BE3J0PSAER    the one that had stuck at "prepared"
anchor_sha            c643aaab87b4b1f79b9f1d909bf8788a0e25fe61
pickup_acknowledged   true
new epoch             06G4AC5KX225JGXAEWH52NNPB0
new pane              created 01:47:09
session phase         research
task                  leased v9, work_phase research, lifecycle started
Step Evidence
Handoff answer parses F31, no invalid handoff answer
Handoff validates F36 and F37, handoff_ref exists
Anchor pushed anchor_sha c643aaab
Release commits transaction gone from releases
Predecessor slot frees old session dropped
Successor leases and starts new epoch, new pane, phase research
Pickup validates pickup_acknowledged: true

No lifecycle shepherding. The only operator action in this run was one Enter into a pane holding unsent input, which F35 turns into an action.

Two fixes proved themselves incidentally in the same event. The relaunch logged submit_attempts=2, so F33's resend path does real recovery rather than existing only in tests. The new lease carries progress_sha at once, so F34's launch baseline survives into the real worker path.

Remaining gates

research  -> research.json sealed -> request -> rotation -> plan launch carries accepted research
plan live -> correction comment -> HumanDecisionRecorded -> RemoteTurn -> DecisionNotice receipt, once
plan.json sealed -> request -> rotation
implement launch -> correction above accepted plan above accepted research

The correction is held until the plan successor is definitely live. Posting earlier still exercises F20 and F23, but it cannot prove a mid-plan correction outranks accepted research and the current trajectory.

F38 and F39: the agent guessed a schema, and was never told it guessed wrong

Run 5 sealed research.json at 01:47:52 and wrote {"from": "research", "to": "plan"}. Nothing moved for five minutes. The operator noticed before the system reported anything.

research artifact: json: cannot unmarshal string into Go struct
field Research.dead_ends of type workphase.DeadEnd

F38: the brief said Seal .orchestra/research.json before you ask and described the contents in prose. It never stated the schema. The agent wrote dead_ends as strings where the decoder wants {tried, why_failed} objects. Everything else in the artifact was correct.

F39: requestPhase refused through recordError alone. answerRefusedPhase ran only on a coordinator 409, so a local decode failure reached nobody. The session parked at a boundary with no feedback, and worker health holds one error slot (F18), so even that trace was overwritable.

F39 is the serious half. F38 alone costs one corrected file. F39 turns any local refusal into a silent stall.

Fixed in 0bd86e2. The brief carries the shape, and every local refusal now reaches the agent. TestPhaseSealSchemasDecode decodes each documented shape with the function the worker uses: flipping dead_ends back to strings reproduces run 5's exact error at build time.

Run 5: the whole chain, live, 2026-08-28 02:05

Filed as kami/test-e2e#5, task 06G4A4F0TFXKZHJE48N05XN1HG, harness workpc-claude, both halves on cd1675a.

01:13:19  launched, then stalled: F33 confirmed an editor that had not rendered
02:00:05  worker restarted on cd1675a
02:00:06  phase request refused, delivered into the pane (F39)
02:02     agent rewrote research.json, unprompted by any human
02:03:00  research to plan accepted
02:03:31  plan session launched carrying accepted research
02:04:05  plan.json refused: changes[0].intent 840 characters exceeds the 500 bound
02:04:07  operator correction posted to the issue
02:04:10  delivered to the live plan session, decision 06G4AG40NR866DVD875ERFZT70
02:04:30  plan to implement accepted
02:04:37  plan.json names it: "Human decision: JSON keys must be snake_case..."
02:05:06  implement session launched

Three seconds from comment to delivery. delivered_decisions holds exactly one id, so the notice did not repeat.

The authority order, as rendered

## Goal
## Acceptance
## Current human decisions
   - correction (operator_instruction): for --json, use snake_case keys.
     keep the default text output byte-for-byte unchanged.
## Current phase              implement
## Repository rules
## Verified git state
## Accepted research
## Accepted plan
## Continuity from the previous session
### Dead ends already tried

Human correction above accepted plan, above accepted research, stale continuity below all of them. That is the target chain, live.

What each fix bought, observed rather than argued

  • F33: the 02:03:31 and 02:05:06 launches both logged submit_attempts=2. The resend path is doing real recovery on every rotation.
  • F39: two refusals delivered, one for research.json and one for plan.json. The agent corrected both without a human touching anything. Before tonight each would have been a silent stall until lease expiry.
  • F38: the plan brief carried the plan schema, so the plan artifact failed on a length bound rather than on a guessed shape.
  • F20 and F23: one comment, one decision, one delivery, one receipt.

Standing caveat

Run 5 is implementing, not finished. The chain this run set out to prove is proven. Completion, the review phase, and the final report are still ahead.

F40: the terminal phase never learned how to finish

Run 5's review agent finished at 02:12 and stopped. No phase-request.json, no handoff, no .orchestra/done, and nothing in the worker journal after 02:06:21.

legalPhaseTransitions makes review terminal: its only move is backwards to implement, for rework. A review that passes has nothing to ask for.

The worker has always finalised on .orchestra/done (cmd/orchestra-worker/main.go:546). No brief ever named that file. Grepping a rendered launch.md for it returned zero matches, in every phase.

So the completion signal existed on one side of the contract only. An agent whose review passed had no instruction at all, and stopping was the correct reading of what it had been told.

Fixed in the phase brief: the terminal phase now states the marker, when to write it, and that it is exclusive with asking to go back. TestTerminalPhaseNamesTheCompletionSignal also asserts the negative, so a phase that can still ask is never told to finish instead.

F41: harness chrome counted as agent progress

Run 5's review agent produced nothing after 02:12. The 02:46 renewal was granted anyway, and the lease moved to 03:16. Each renewal window bought another 30 minutes, so recovery by expiry had no bound.

Reproduced the worker's stored progress_sha byte for byte from the live pane, so the branch taken at cmd/orchestra-worker/main.go:1020 was progress != l.ProgressSHA. Not IsBusy: the 200-line capture held zero esc to interrupt markers, so AgentStatus returned idle. Not an empty baseline either: it held 3d93ca89.

PaneProgress dropped prompt lines only, so Claude Code's status footer stayed in the digest. The mutable fields there are the rolling usage percentage, the context counter, and the version notice. A 180-second sample showed no churn, so this leaked intermittently rather than constantly. One tick inside a window was enough.

Fixed in 2417a39. PaneProgress cuts from the editor's lower rule and then trims the spinner summary and version notice above it. AgentStatus still reads the raw capture, so the busy markers living in the footer are unaffected.

Proven live in both directions on the first window after deployment: the 03:13:48 renewal took version 28, the 03:16:13 window was refused, the lease expired at 03:18:43, and review relaunched at 03:20:14 with the F40 brief. The relaunched pane then showed a new footer field, 5h: 15%, which the old digest would have counted as work.

Lease TTL moved to 5 minutes in the same commit, from domain.LeaseTTL, with renewal at half of it. Reclaiming a stalled pane happens only at expiry.

F42: the result commit refused the completion marker

The relaunched review agent wrote .orchestra/done at 03:20:48, 34 seconds after launch. The worker recognised it, confirmed the agent idle, and then failed the result commit once every five seconds:

stage result: The following paths are ignored by one of your .gitignore files:
.orchestra/done

internal/herdr/adapter.go:132 writes .orchestra/.gitignore containing *, so the marker is ignored. The staging step passed :!.orchestra/done, and git refuses an add whose pathspec names an ignored path. Reproduced in a scratch repo on git 2.55.0, both the failing and the working form.

Fixed in dcd9af4: the exclusion names the directory, :!.orchestra. That holds whether or not the inner .gitignore exists.

The failure retried for 22 minutes with the task stuck in review and nothing observable outside the journal, because each identical error overwrote the single last_error slot. F18, again, and the second time in one night that it turned a five-second loop into archaeology.

Run 5 reached TaskCompleted

On the first tick after the dcd9af4 restart at 06:47:50:

  • task state completed, lease released, .orchestra/done removed
  • pane closed, no orchestra-* sessions left on the tmux socket
  • result branch pushed: refs/heads/orchestra/scratch/orchestra-06g4a4f0tfxkzhje48n05xn1hg-73a2e4fd at fb15c61

Two links of the tail did not run, and neither is a defect. The deployed test-e2e project declares only id, machine_affinity, repo and worktree_root, so no quality gate exists to run. The deployed config.jsonc has no sources and no delivery keys, so no PR could be opened.

Proven chain: review, done recognised, finalise, result branch pushed, TaskCompleted. Submission, human PR review and merge remain unproven, and they need registry configuration rather than code.

Run 6, 2026-08-28: the PR tail, and the baseline conformance run

The last handoff said submission, human review and merge needed configuration rather than code. That was wrong. The whole event log held zero ReviewRecorded and zero TaskSubmitted: nothing in the running system ever called either endpoint, so the publisher, the merge reflection and the human trust boundary had no entry point at all. Four links were missing, and three more defects surfaced while proving them.

F43, the reviewed change had no path to the human

The worker's completion tail called federation.Client.Complete directly and emitted TaskCompleted. POST /v1/tasks/{id}/review and POST /v1/tasks/{id}/submission existed and had no live caller; task pr is named in six documents and implemented nowhere. GiteaPublisher.Push also ran git push from projectRoots[project]/<task> on the coordinator, a directory that does not exist for a worker-owned project whose worktree is on workpc.

Fixed in e8d04d7:

  • finalize commits first and runs the gate against the committed tree, so GateResult.SHA is the commit being submitted. CheckSubmission requires gate sha, review sha and head sha to be one commit, which a gate run on the pre-commit tree can never satisfy.
  • POST /v1/federation/workers/<id>/submit seals the review and submits. A blocking review returns the task to implementation; a project with no forge still completes directly.
  • The reviewer's brief names .orchestra/review.json and no longer claims a diff is supplied. Nothing populates agentctx.Evidence on the federated path, so the brief described material the session never received.
  • Push asks the forge what the branch holds before reaching for a checkout.

F44, the Gitea source ingested Orchestra's own pull requests

The first submission this deployment ever made, kami/test-e2e#8, came back one minute later as task 06G4E83E4KRXM8DS90M2648MGM with the submission packet as its description. That task would have implemented, reviewed and submitted again, opening a pull request per cycle. The issues endpoint returns pull requests and nothing filtered them. The webhook had the same hole from the other side: a pull_request delivery leaves the issue key empty and would have appended a task numbered 0 with no title.

Contained by blocking the spawned task, fixed in 6ccc755.

F45, the submission reflection loop was dead code

if len(pullRequests) > 0 guarded the loop 600 lines before the Gitea wiring that writes to that map. The length was always zero, the goroutine never started, and a merged pull request could never complete its task. Live: a trusted comment on PR #8 moved nothing until 4af9880 moved the block below the wiring.

F46, stale review findings survive a changes-requested round trip

The worktree is not recreated, so .orchestra/review.json from the first review is still present when the second one starts. A reviewer that writes .orchestra/done without rewriting it would have the earlier findings sealed against the new commit, and a stale pass is indistinguishable from a fresh one. Observed at 10:57:48 with a file from 10:55:45. That reviewer did rewrite it, so the run stands. Fixed in 063a3ab.

Baseline conformance run

Task 06G4E6F69AKP2PA00S28D7ASBC, issue kami/test-e2e#7, workpc-claude, tmux backend. Coordinator 4af9880, worker e8d04d7. No manual lifecycle intervention: every event below was emitted by the plane.

06:41:14  TaskCreated          issue #7 polled
06:41:37  WorkPhaseChanged     frame -> research
06:42:57  WorkPhaseChanged     research -> plan
06:45:12  WorkPhaseChanged     plan -> implement
06:46:27  WorkPhaseChanged     implement -> review
06:47:48  ReviewRecorded       blocking 0, sha 41dee802
06:47:49  TaskSubmitted        PR 8 opened, sha 41dee802
06:55:11  TaskChangesRequested trusted comment by kami
06:55:11  WorkPhaseChanged     review -> implement
06:57:47  WorkPhaseChanged     implement -> review
07:00:09  ReviewRecorded       blocking 0, sha 0d1585df
07:00:09  TaskSubmitted        PR 8 reused, sha 0d1585df
07:02:07  merge                by kami
07:02:11  TaskCompleted        receipt binds merge f48e363d

Acceptance, each checked against the artifact rather than the log line:

  • The gate ran against the exact submitted commit. Gate artifact bd3549d6: {"command":"bash -n scripts/*.sh && bash scripts/orchestra_e2e_healthcheck.sh","exit_code":0,"sha":"0d1585df"}.
  • TaskSubmitted binds all five: gate sha, review sha, result sha, remote ref origin/orchestra/06G4E6F69AKP2PA00S28D7ASBC, and PR 8. The PR head reads 0d1585df at the forge.
  • Resubmission reused PR 8 with a new gate ref and a new review ref.
  • A trusted human reopened the task and the change reached merged master: --quiet is documented in --help, which is what the comment asked for.
  • Merge produced the completion receipt: merge_sha f48e363d, submitted_sha 0d1585df, submission_ref 06G4EASR63SZ1WWRYHR62WJQS4.
  • Final state completed.

Freeze this as the baseline. One caveat on the reopen: Gitea returns 422 for a review on your own pull request, and Orchestra opens the PR as the same forge user the operator reviews as. REQUEST_CHANGES is therefore unreachable in this deployment and the comment path is what was exercised. A separate bot account for ORCHESTRA_GITEA_TOKEN would restore it.

Configuration this needed, for the record: quality_gate on test-e2e in both config.jsonc (coordinator) and /etc/orchestra/worker-projects.json (worker, which is the copy finalize actually reads), plus ORCHESTRA_REVIEW_ACTORS=kami in the compose .env.

Plan machinery, written 2026-08-28, unproven live

Four commits above orchestra-conformance-v1. Nothing deployed. Full detail is in HANDOFF-2026-08-28-plan-machinery.md; this is the ledger entry.

Commit What
822f086 Research findings gain id and confidence. The advertised schema becomes true.
57c028f plan.md replaces the four-bullet-list plan artifact. Renders verbatim into the implement launch.
a221502 Plan phase progress Orchestra establishes, plus the project verification allowlist.
c76112a Typed plan mismatch and the Orchestra-owned reopen.

The burn-in it needs

Configuration first: test-e2e needs a verification block in the coordinator's config.jsonc. Absent policy refuses every plan command, so without it a plan seals and no phase can ever verify. Both halves must be rebuilt and redeployed together, because the worker calls two new federation routes.

Then the ladder:

1. plan seals three detailed phases
   -> grep the rendered launch.md for the phase-three verification command
2. phase 1 requests verification
   -> worker runs the exact plan command
   -> durable state says verified
3. rotation
   -> successor gets the complete original plan
   -> phase 1 verified, phase 2 explicitly current
4. human correction lands above the plan
5. phase 2 contradiction
   -> PlanMismatchRecorded, no improvisation
   -> old plan retained until the revised one seals
   -> fresh launch carries the revised plan in full
6. a plan command outside project policy
   -> refused at seal time, on the planner
7. a legacy plan
   -> "phase progress unavailable", task still completes

Steps 1 and 3 are the rungs that matter for smaller local models. Steps 6 and 7 were added from what this session's implementation surfaced.

Freeze as orchestra-plan-v1 if it passes.

F18 is now blocking

Three more instances landed with this change: every refusal in the new plan machinery records into the same single last_error slot. That is the fourth, fifth and sixth instance. The plan burn-in will produce exactly the five-second retry loops the bounded observation ring exists to make visible, so fix F18 before running it.

Runs 7 to 10, the plan machinery burn-in, 2026-08-28

Both halves were rebuilt and redeployed together seven times across these runs, ending at 38aa073. Every revision below was verified from the running process, not the installed file.

test-e2e gained a verification policy in the coordinator's config.jsonc:

"verification": { "allowed": [["bash", "-n", "*"], ["bash", "scripts/orchestra_e2e_healthcheck.sh"]] }

The policy has one configuration copy, not two. The worker never reads it: requestPlanVerification calls PlanPhaseCommands on the coordinator and runs only what comes back. A verification field in /etc/orchestra/worker-projects.json would be dead configuration that later reads as authoritative.

What is proven

Rung Evidence
Research findings are citable Run 10: seven findings, three confidence classes, five cited by the plan
The plan seals as a specification Run 8: 7940 bytes, three phases, five run: lines, one manual step
The plan renders verbatim into implement Run 8: the whole 7940-byte document byte for byte inside a 20822-byte launch.md, phase 3 heading and command present
Orchestra establishes phase progress Run 8: phase-1 verified at exit [0], bound to plan_ref, at_sha, evidence_ref, harness and lease epoch
A manual step holds the phase Runs 8, 9, 10: awaiting_manual_verification on every phase declaring one
A generic comment does not satisfy it Run 8: "looks good to me" landed as operator_instruction, phase 2 unmoved
A keyed sign-off does Run 8: plan_phase_verification:51afbfa9…:phase-2, phase 2 verified
Rotation preserves progress Run 8: the successor received the whole plan and the progress block
Verification is bound to a SHA Run 8: three phases rendered "verified at 14654d6cab63, stale because the tree is now at 56d9b9acdd59"
Reverification moves with the tree Run 10: all three phases reverified at ec0502f after HEAD moved
A plan command outside policy is refused at seal Run 10: refused on the planner, corrected in 25 seconds, then sealed
A legacy plan degrades honestly Decoded from the real CAS artifact 1e12fcf0: zero phases, 1646 bytes of markdown, the legacy notice present, the progress block empty
Review, resubmission and merge still work Run 8: PR 11, changes requested, resubmitted, merged, receipt binds ec320c82

The typed mismatch and the reopen, run 11

Proven whole, on the fourth attempt. The three failed attempts are worth keeping, because each one was the agent being right.

The setup that worked removes the planner from the loop and the race with it. Let research seal normally, then POST /v1/tasks/<id>/phase from the TUI surface with a plan authored to assert something false about the repository. Run 11's plan said scripts/orchestra_e2e_healthcheck.sh defines an emit_json() helper at line 12 with three call sites and a format global. The script has none of those.

13:01:28  implement launched against the false plan
13:01:56  PlanMismatchRecorded  phase-1  requested_action=replan
13:01:56  plan reopened; plan_ref still 91c4dbd4; plan_history empty
13:02:33  replanning session launched
13:03:42  replacement refused at seal, a command outside verification policy
13:04:46  corrected replacement seals
          plan_ref      91c4dbd4 -> b9bcb60a
          plan_history  []       -> ['91c4dbd4']
          plan_progress empty
13:05:28  fresh implement session, 7827 bytes of replacement plan verbatim,
          phase-1 "not started"

The report carried an observation and no replacement plan:

observed:          "defines no helper at all. Line 12 is 'exit 0' inside the
                    --help branch. Output leaves the script from four bare
                    printf calls (lines 8-11 help, line 21 json, line 25 plain
                    success)..."
contradicts:       "Phase 1 edits that helper, Phase 2 moves its three call
                    sites, and Phase 3 deletes the global. None of those three
                    targets exist in the file."
evidence:          six file:line citations
requested_action:  replan

Every authority property held. The agent reported and did not decide. A recorded mismatch did not supersede the accepted plan, which stayed accepted for the whole replanning window. Progress did not carry across the ref change.

fb7135e fired a second time inside this run, on the replacement plan, which is the seal-time refusal working on a plan nobody set up to fail.

Three attempts that failed, and why they were wrong

Each of these tested something that is not a plan mismatch. The agent was right every time:

  • Rewriting a file the plan depends on is reversible. The implementer restored it from master and commented "Restore the byte-pinned USAGE test_healthcheck.sh from master". The plan said the file gets no edit, an edit appeared, and restoring it satisfied the plan.

  • A human correction is authoritative. The brief tells the agent a decision outranks the plan, so it followed the correction and recorded the stale plan as an outstanding item: "The accepted plan text still specifies exit 2 and an in-loop chain; it is stale against the correction."

  • Letting a competent planner author the plan cannot produce this case at all. It researches first, so its premises are true.

plan-mismatch.json is for the case where the repository contradicts the plan and no human has spoken. In a repository the agent fully controls, that has to be constructed: a false premise the implementer cannot repair and cannot rewrite.

Defects found, all fixed

ID Commit Defect
F47 4b32080 Delivery merges the task branch, which carries orchestra: TASK.md. writeTaskFile returned early on os.Stat, so every later worktree inherited the previous task's file and every release failed "TASK.md changed". Run 7 died on retry_limit without leaving research.
F48 49409c9 The manual plan-phase gate had two live consumers and no producer. Every imported comment was hardcoded to operator_instruction, so a phase with a manual step could never be verified. A comment whose first line is orchestra verify <phase-id> now carries the keyed subject.
F49 f4dbcf7 A plan whose every phase was stale printed "Every phase is verified" under three lines that each said stale.
F50 4712c7d The research brief never stated the finding-id format the decoder enforces. Cost one boundary.
F51 98f1b2d, fda78cf The handoff parser split on only and demanded the words "tried" and "failed because". Cost four leases across run 9 on content that was exactly right.
F52 fb7135e A plan command outside project policy sealed anyway. The only caller of VerificationPolicy.Allows was PlanPhaseCommands, which runs when the implementer asks to verify: one phase and one rotation too late.
F53 d7e75e9 Only a verified phase consulted Stale. A pending manual gate rendered "automated checks passed at 94bd45c3b5d6" against a tree at 7d04aef.
F54 015764e The handoff prompt stated the 200-character limit for NEXT, WHY and REMAINING. The validator applies it to OPEN Q and LEARNED too.
F56 44ff35a publishCaptures iterated every session the worker held, without checking the lease. Run 10's task was blocked and unleased for twenty-six minutes while this called the coordinator every five seconds and logged "409 Conflict: lease not owned" each time. It also kept the single last_error slot pinned to a dead task, hiding run 11's own failures.
F55 38aa073 A refused handoff had no feedback loop. PrepareRelease read the report, the parser refused it, the worker recorded it in health, and the next boundary read the same bytes. Run 10 spent four leases there. answerRefusedHandoff now says why and drops the file, gated on a typed ErrInvalidHandoffAnswer so transport failures keep their retry.

F50, F51 and F54 are one shape: a constraint the code enforces and no brief states. F55 is the shape that made them expensive, and it is the silent-loop pattern CLAUDE.md names.

fb7135e has operational reach. Eight projects in config.jsonc carry no verification block: inference-router, Muzick, Kdrive, Nexus, Praxis, Hexis, manga-pipeline, orchestra. Their plans still seal, but only with manual verification. A run: line is now refused on the planner.

Run 11 completed the lifecycle on the final build

The same task that produced the mismatch went on through review, submission and merge without intervention. PR 16, receipt binds merge bf0f287a to submitted 5bcffb33. That is the frozen lifecycle re-proven above every fix in this session.

Deployed state at the freeze

Half Revision
Coordinator, homesrv container 44ff35a
Worker, workpc systemd 44ff35a

Run 11 itself ran on 38aa073. 44ff35a is one commit above it, unit-tested and deployed, not exercised by a full run.

Recorded, not fixed

  • The operator lifecycle actions lose a version race. block, release and attention return task version conflict on a leased task, every attempt, because the handler reads the version and the worker writes between the read and the append. They succeed once the task is quiet.
  • master carries a stale TASK.md. F47 makes it harmless, but every pull request now shows a cosmetic TASK.md diff.
  • F18 is still open, and it cost real diagnosis time twice. The single last_error slot held only the most recent failure, so run 7's three earlier failures were overwritten before they could be read. In run 11 the slot was pinned to a different, blocked task for twenty-six minutes (F56), so run 11's own expiry reason was never visible at all.
  • A release transaction whose lease has expired can never commit. Run 10 ended this way: PrepareRelease succeeded, Release returned 409 lease not owned, and the transaction stayed in anchor_pushed across every relaunch until retry_limit. The agent's completed work sat uncommitted in the worktree throughout.

Things that will bite

  • The agent pane is on a private tmux socket: tmux -L orchestra capture-pane -p -t <session>:1.0. A bare tmux ls does not show it.
  • A trivial task finishes three plan phases in about 105 seconds. Any intervention timed against the implement phase will lose that race.
  • sudo is unavailable in this sandbox, so every worker-side fix needs the operator. Batch them: one install per run, not one per defect.

Run 12, 2026-08-28: the expired release, proven both ways

Run 12 exists to settle one question the freeze left open: a release transaction whose lease has expired can never commit. Run 10 ended that way and lost a finished task. The fix and its guard were both proven live on test-e2e, on a task built for the purpose.

F57: an expired lease could never commit the anchor it had already pushed

6565b9f. A release pushes the anchor first and commits second. When the lease died in between, the commit could never land, for two reasons at once:

  • The worker sent the epoch from w.leases, which the expiry replay had already deleted, so the request carried an empty epoch.
  • The coordinator refused any /handoff without a live owned lease, and expiry had already moved the version the worker held.

The epoch now belongs to the release transaction, so it survives the lease. TaskReleased retains the ending epoch as Task.LastLeaseEpoch, and lateHandoffAccepted lets exactly that owner commit while the task is queued, unleased, and carrying no handoff of its own.

The rig, and why suspending the worker cannot produce this

The ordering only exists inside one call: transaction opened, anchor pushing, commit not yet sent. The event replay runs at the top of every tick, so any expiry the worker learns about before pushing discards the transaction and quarantines the session, by the F30 rule. A SIGSTOP therefore cannot make it happen; the pause has to land inside the push.

What worked: poll /var/lib/orchestra/worker-state/workpc-claude.json at 2ms and fire POST /v1/tasks/<id>/release the instant a transaction appears at prepared. The forced expiry is the real expiry path, not a shortcut. validateTransition accepts reason: lease_expired only when the payload binds the current harness_id and lease_epoch, which is the same rule the coordinator's own sweep obeys.

Task 06G4KENHXY12M5BNC5TXAF3MXR, transaction 06G4KFK9EV4JZEPFEVEB65K2KM:

19:00:10.742  transaction opens, lease_epoch 06G4KF6HC66AGNG5HZZXCHRYPG, phase prepared
19:00:10.727  TaskReleased v15  reason=lease_expired  epoch=06G4KF6HC66AGNG5HZZXCHRYPG  surface=tui
19:00:11.662  TaskReleased v16  tx=06G4KFK9EV4JZEPFEVEB65K2KM  hr=d4b203e9e5c0  surface=system
19:00:11.665  TaskLeased  v17   epoch=06G4KFKD27HCR7MBS37M0T48AR  hr=d4b203e9e5c0
19:00:15.524  TaskPickupValidated v18

The commit landed 935ms after the lease died. On 44ff35a, v16 is 409 lease not owned and the anchor is stranded.

The race guard, proven by making a successor win

Same rig, one step added: lease the task to race-guard-probe immediately after the forced expiry, before the push finishes.

19:01:30.539  transaction 06G4KFX15CW9S3967FV7R09GSM opens, epoch 06G4KFKD27HCR7MBS37M0T48AR
19:01:30.525  TaskReleased v22  reason=lease_expired
19:01:30.538  TaskLeased  v23   harness=race-guard-probe  epoch=06G4KFX15881BB6BX5X0YMV988
              late commit -> 409 lease not owned, no v24 handoff, successor lease intact

A named probe harness is the cheap way to own a lease without starting an agent. Nothing picks it up and it expires on the normal TTL.

F58: a superseded release transaction retried forever

03663f4, and it is F57's own residue. That 409 is correct and permanent: the late-handoff path fences on the epoch that expired, and the owner has moved on twice. The worker kept asking every five seconds anyway, holding the pane and pinning both ActiveTask and the single last_error slot. Run 10's task did that for seven hours, which is also what hid run 11's failures.

TaskLeased now abandons a release transaction whose id the lease does not carry, and quarantines its session. A successor pickup carries the predecessor's own transaction id, so the recoverable predecessor F30 protects is left alone.

F59: a failed task kept its release transaction too

8e37989. F58 fires on TaskLeased, and a failed task is never leased again. Run 12's own rig task proved the gap within ten minutes: two forced expiries plus the probe's expiry pushed it to retry_limit, and it failed still holding a transaction whose commit is refused permanently. TaskFailed now drops the transaction and quarantines the session even when the anchor was pushed. Blocked keeps the old rule, because a reopen still produces a successor that can pick the anchor up.

A blocked task that is never reopened is therefore still able to loop. That is run 10's case, and it is the one shape left that needs a hand.

Corrected from the freeze

The operator lifecycle actions do not lose a version race. block, release and attention are refused on a leased task by internal/store/store.go:885-901 when the payload omits harness_id and lease_epoch. Ten attempts in 550ms all failed that way. Sending the two fencing fields makes them succeed on the first try. The previous entry blamed a read-then-append race, and that was wrong.

Deployed state

Half Revision
Coordinator, homesrv container 8e37989
Worker, workpc systemd 8e37989
commit              8e37989526d8ea14088872138e32438b0df061c3
coordinator sha256  e2b3a2bb5ec374b7eae46586476712a16b43bee11039a910997d31f56f72903c
worker sha256       204f3c82b0b20aa2f86dfec3d117f4765678a86ce76bbfe43f9c80bfbd4cc245

The coordinator sha256 is /app/orchestra inside the container. Docker compiles its own binary, so it never matches build/orchestra. F57 shipped on 6565b9f and F58 on 03663f4; both were deployed and verified in turn, and 8e37989 is the pair that is live.

The operator installed the /etc/sudoers.d line the freeze offered, so worker installs no longer need a human. The batching advice still stands: each install is still a restart of live state.

Cleanup done, and one left

Two dead transactions were dropped from the worker state by hand, each with the worker suspended and the file backed up:

Task Backup Why the code could not clear it
06G4GBSQ2WRGD5HGYPYZZ4TYH0 .bak-preclean-03663f4 Run 10's. Blocked, so TaskLeased never comes.
06G4KENHXY12M5BNC5TXAF3MXR .bak-preclean-8e37989 Run 12's rig task. Its TaskFailed was already behind the worker's cursor when F59 deployed.

Both panes are still orphaned and need an operator kill. The worker no longer holds either session, so nothing will close them:

orchestra-06g4gbsq2wrgd5hgypyzz4tyh0-ec8111ad:1.0
orchestra-06g4kenhxy12m5bnc5txaf3mxr-*:1.0
tmux -L orchestra kill-session -t <session>

Task 06G4JX6MSQEP7N0D5JWW9EP5X4 is the other run 12 task and is in_review on a pull request. It is real work and should be reviewed or failed, not cleaned.

F18 closed: a bounded observation ring

d6ee10f. WorkerHealth now carries up to sixteen distinct observations, each with a repeat count and first/last times. Collapsing is by message, not by position, because a loop interleaved with other failures would otherwise still flush the ring. Eviction drops the least recently seen, so a loop keeps its slot but carries the count that says what it is. last_error and error_at keep their wire names and still report only the newest failure.

The ring lives in memory beside last_error and is not persisted, which is what last_error already did across a restart.

Deployed and verified on both halves. It reports nothing yet, because omitempty hides an empty ring and nothing has failed since the deploy. No UI renders it: internal/ui never showed last_error either, so GET /v1/federation/workers is the only reader today.

Run 12 closed out

PR 17 merged, task 06G4JX6MSQEP7N0D5JWW9EP5X4 completed at v32, pane closed by the normal completion path. The quality gate was re-run independently against the submitted commit 419e17fc before the merge, rather than trusted from the worker's own report:

bash -n scripts/*.sh                        syntax ok
bash scripts/orchestra_e2e_healthcheck.sh   OK - all healthchecks passed, exit 0
bash scripts/test_healthcheck.sh            all checks passed, exit 0

Worker state is empty: no sessions, no leases, no release transactions, and tmux -L orchestra ls lists nothing. The two panes the previous section left for an operator were already gone by then.

Half Revision
Coordinator, homesrv container d6ee10f
Worker, workpc systemd d6ee10f
commit              d6ee10f02843e2267c859b03d17db00995941e64
coordinator sha256  f8a847477ae0ac03fe78338775f52ed1f167f0e67eab557feffe4d67da3ad99e
worker sha256       fe7d2188b12369fb7682d3424fa11e2db47abed9c55313291a4b467b5972d02d

Still open after run 12

  • 29 blocked test-e2e tasks, mostly burn-in debris. Operator hygiene, not lifecycle code.
  • Three queued correx tasks that cannot be scheduled, because correx has no entry in the coordinator's config.jsonc. Either give it one or block them explicitly.
  • Gitea returns 422 for a review on your own pull request, so REQUEST_CHANGES needs a separate bot account for ORCHESTRA_GITEA_TOKEN.
  • A blocked task that is never reopened can still loop on a dead release transaction. F59 covers failed, not blocked.

Run 13, 2026-08-28: F60, and the ring proven under a real failure

F60: a transaction must settle deterministically, in every case

3c7cf95. F58 and F59 each fixed one case of the same rule. F60 states the rule and covers the rest:

a transaction that can no longer be legitimately committed must be abandoned deterministically, and must never spin on an answer that cannot change.

  • Terminal is failed or completed. Both drop the transaction and free the session. Completed was the gap F59 left.
  • Blocked keeps the transaction, because a reopen returns the task to the queue and that exact owner can still commit.
  • TaskBlocked now retains the ending epoch, as TaskReleased already did. Without it a reopened task has no epoch for the late-handoff path to fence against, so keeping the transaction would be a lie.
  • A refusal parks the commit, 30s backing off to a 5 minute cap. Any event about the task un-parks it. A reopen arrives as TaskCorrected, so the rule cannot be a list of event types.
  • A transport failure is not an answer and retries at once. The park keys on a 4xx StatusError, never on any error.

Proven live, and the contrast with run 10

Task 06G4KVSHAK9B8M9K8HENAF23CG, same race-guard rig: force the expiry inside the push, then hand the task to race-guard-probe.

19:54:10.888  transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8 opens at prepared
19:54:10.907  forced expiry accepted
19:54:10.916  successor lease to race-guard-probe accepted
19:54:11.776  commit refused once, 409 lease not owned, parked
19:54:15.741  superseded by lease 06G4KVYSXJH053K3DAG6FKHD20, transaction abandoned

Run 10's equivalent ran roughly 5,000 retries over seven hours and needed an operator to clear the state file by hand. This one asked once and settled itself in four seconds. Worker state afterwards: no sessions, no leases, no transactions, and tmux -L orchestra ls empty.

F18 populated under a real failure

The gap the previous section recorded is closed. The same run produced this health payload, two distinct observations rather than one overwritten slot:

"observations": [
 {"message": "release 06G4KVSHAK9B8M9K8HENAF23CG commit: federation: 409 Conflict: lease not owned",
  "count": 1, "first": "2026-08-28T19:54:11.776815524Z", "last": "2026-08-28T19:54:11.776815524Z"},
 {"message": "release 06G4KVSHAK9B8M9K8HENAF23CG superseded by lease 06G4KVYSXJH053K3DAG6FKHD20: abandoning transaction 06G4KVYT91GQH5ZYY6CNG6FWZ8",
  "count": 1, "first": "2026-08-28T19:54:15.74116014Z", "last": "2026-08-28T19:54:15.74116014Z"}
]

Under the old single slot the abandonment message would have erased the 409 that caused it, and the causal chain would have been unreadable. Both counts are 1, which is itself the F60 evidence: nothing looped.

Deployed state

Half Revision
Coordinator, homesrv container 3c7cf95
Worker, workpc systemd 3c7cf95
commit              3c7cf95d8cdaaff5dcec460c5d7874f7f4f0dbea
coordinator sha256  3fec1e99a638da851f5c970abaf80a006dc87425936f71fe33f10830f4bac573
worker sha256       a373445f167f0a321dfc3866a1d2ed703b670b0fb3c188a8d00ad8099586dfdf

orchestra-f18-baseline tags d6ee10f, the post-F18 deployed baseline, and is pushed. The branch is pushed too: 91 commits, because nothing had gone up since 97a9c65 on 2026-07-31.

Still open

Unchanged and all operational rather than runtime: 29 blocked test-e2e tasks, three unschedulable correx tasks, and the Gitea 422 on reviewing your own pull request. The rig task above will requeue when the probe lease expires and start fresh, which is correct: its anchor was abandoned, not committed.

Run 14, 2026-08-28: plan-phase execution proven on 3c7cf95

Four tasks. Two carried a full lifecycle to a pull request, one failed for a cause outside Orchestra, and the F60 rig task failed on its retry budget.

The rung: the worker runs the sealed plan's commands

Task 06G4M8WHGQ4P3GQMPEEH0RJRHM, plan 789ed6a8477b, both phases verified against the plan document rather than the request.

plan phase-1  - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
              - run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
event  v19    commands identical, exit_codes [0,0], at_sha 3b66b2b6a669,
              evidence_ref a5dbc4ecc9ce, status awaiting_manual_verification

plan phase-2  - run: ["bash", "-n", "scripts/orchestra_e2e_healthcheck.sh"]
              - run: ["bash", "-n", "scripts/test_healthcheck.sh"]
              - run: ["bash", "scripts/orchestra_e2e_healthcheck.sh"]
event  v20    commands identical, exit_codes [0,0,0], same at_sha and plan_ref

Every record binds plan_ref, phase_id, at_sha, evidence_ref, lease_epoch and harness_id. Rotation ran between every work phase with TaskPickupValidated each time.

Not yet proven: a successor picking up mid-implement. Both phases verified under one lease epoch, so no rotation separated phase 1 from phase 2.

The planner found the legal route to a forbidden command. scripts/test_healthcheck.sh may not be executed, but ["bash", "-n", ...] covers any path, so it syntax-checked the file it could not run.

The refusal loop is actionable, not merely observable

Task 06G4M6HF1Z3EREX1X3NEKSHP24 sealed a plan naming bash scripts/test_healthcheck.sh, which the policy forbids.

20:45:02  phase request refused: "bash scripts/test_healthcheck.sh" is not in the policy
20:45:33  implement          <- the planner rewrote and resealed, 31 seconds later
20:48:03  in_review

F52 refused on the planner, F55's answer path delivered the reason, and the agent corrected. Count stayed at 1 for both tasks that hit it. A brief that states the policy avoids the round trip entirely: the --list-checks task was told the allowed argv and sealed on the first attempt.

F61, recorded and deliberately not built

The verification allow-list is enforced and never stated to the planner. Every plan that guesses pays one refused round trip. It is not a one-liner: agentctx.Input.Policy is filled from the worker's SafeOperations, while the verification policy is coordinator-side by design, so this needs a new channel.

The reason to promote it later is local models. A strong planner consumed the refusal and repaired the plan in 31 seconds. A weaker one may propose forbidden commands repeatedly, because it cannot infer the allowed substitute. Then F61 stops being latency and becomes model compatibility.

opencode is not running, and the reason was only in the pane

Task 06G4M6HH5BAM235AC4PWYXF1HM failed at attempt 3 having never left frame. Its worktree held only launch.md. The pane said why. The blocked thing is the selected model, not the OpenCode Zen free tier, which works:

This model is not available in your country.
   ▣  Build · Muse Spark 1.2 Free   OpenCode Zen

Orchestra behaved correctly throughout: lease, no progress, expire, requeue, fail. Two things are worth keeping.

  • The adapter cannot resolve a session file for opencode, so activity is unknown and the worker falls back to agent status. The failure therefore reads as "done and pane unchanged" rather than "dead". Orchestra cannot tell finished from never-started on this harness.
  • No observation carried the cause. Four of them named the lease, the rotation and the activity. The fatal line existed only in the pane capture, which Orchestra publishes but nothing summarises. Parsing harness chrome is not Orchestra's job; surfacing the capture is.

Do not route to workpc-opencode until its model is reachable. Every task sent there burns three attempts and then fails. ~/.config/opencode/opencode.jsonc sets no top-level model, so OpenCode picks its own default. The file already declares reachable nvidia-nim and llama-cpp providers.

The retry budget counts idleness, not just failure

The F60 rig task 06G4KVSHAK9B8M9K8HENAF23CG failed at attempt 3 mid-review. Two attempts were rig-induced expiries and the third was a natural idle expiry. Every lease expiry increments attempt, and an agent going quiet at a phase boundary is a routine expiry here. A task making real progress can spend its retry budget on idleness. The two clean tasks never expired at all, so this is a dynamic to watch rather than a defect to fix.

F59 was confirmed live twice more: both failed tasks left no release transaction and no session behind, on either worker.

Result

Task Harness Outcome
06G4M6HF1Z3EREX1X3NEKSHP24 workpc-claude in_review, recovered from a policy refusal
06G4M8WHGQ4P3GQMPEEH0RJRHM workpc-claude in_review, plan-phase execution proven
06G4M6HH5BAM235AC4PWYXF1HM workpc-opencode failed, model unavailable
06G4KVSHAK9B8M9K8HENAF23CG workpc-claude failed, retry budget

Both workers hold no sessions and no release transactions. Deployed pair is still 3c7cf95 on both halves.

Run 16, 2026-08-29: the rotation rung, half proven

Task 06G4SWEVP71FYYKAV5FV0ZK5ZG on 34f3c28, a three-phase plan (78d25730140c) with automated checks inside policy.

Proven: a successor inherits the whole sealed plan

The launch context of the session that picked the task up after its rotation carries the complete plan: overview, current state, desired end state, non-goals, approach, all three phases with their files, changes and verification, testing strategy, risks, migration, and ten research citations.

Not a defect: that successor was not given phase progress

renderPlanProgress runs only for WorkPhaseImplement (internal/agentctx/agentctx.go:321). This successor picked up in review, and the code says why: an independent review must reconstruct the change from the diff rather than inherit the implementer's account. Progress is withheld there deliberately.

So the rung still needs an implement-phase successor. The records exist and are durable, projected by Task.PlanPhases().

Why the mid-implement rotation missed

A trivial task spends about four minutes in implement, and all three phases verified inside two and a half of them, every one against the same tree 3da86dbf8ad5. The implementer does the whole change first, then verifies each phase. The suspend, edit and restart that sets handoff_requested takes about a minute, and it landed after the implementer had already asked for review.

Two mechanics worth keeping:

  • The web handoff action cannot drive this. RequestHandoff needs a local coordinator and every session here is worker-owned, so it answers 503. That is the Design A guardrail working, and a fourth UI-exposed gap.
  • A state-file edit does not survive a running worker. It holds sessions in memory and writes them back. Suspend, edit, then restart; resuming lets the old copy win. This cost one attempt to learn.

F62: a requested handoff that is never answered is invisible

The rotation is agent-driven: the worker asks, and the agent must write HANDOFF.md at a turn boundary. The review agent never did. Renewals stopped, the lease expired at 10:12:05, and the task requeued having lost an attempt.

Worker health recorded exactly one thing, 34 times:

x34  lease 06G4SWEVP71FYYKAV5FV0ZK5ZG not renewed: agent status idle and pane unchanged

Nothing says a handoff was requested and left unanswered. There is no timeout, no retry, and no observation. The only visible consequence is an expiry that looks identical to an ordinary idle one. Diagnosing it needed the worker state file.

The ring earned its keep again: one distinct message with a count of 34, rather than 34 overwrites of one slot.

Fixed, not yet proven live. The request is now stamped (herdr.Session.HandoffRequestedAt) and the wait is bounded by watchHandoff in the worker:

  • the lease renews while Orchestra is explicitly waiting, because a quiet pane is the answer the agent was asked for — the renewal gate's new case, bounded by handoffAnswerTimeout;
  • the request is re-sent once at handoffRetryAfter (4 minutes), with the same reason it was first asked with;
  • at 10 minutes the worker nacks with failure class handoff_unanswered, and the coordinator emits TaskReleased reason=handoff_unanswered rather than letting the lease die as generic idleness.

DebtClassForFailureClass knows the class, so a harness that repeatedly ignores handoff requests now accumulates in the debt ledger instead of hiding inside lease_expired.

Runtime proof still owed: force a request the agent will not answer, and read the release event rather than the worker state file.

Run 17, 2026-08-29: F62 proven live on c587f2c

Task 06G4V20T528ZTER7KZBVGNAYXC, a five-phase plan (3b16b77054b9) on the deployed pair, coordinator and worker both at c587f2c.

The trigger was production, not a rig

The rig planned for this run was a state-file lever. It was never needed. The implement -> review phase change asked for a handoff through the ordinary path (rotateForPhase, reason phase_changed), and the agent ignored it because this task's brief instructed it to from phase 4 onward. Nothing touched tmux, no process was stopped, and the pane stayed open for the whole wait.

12:48:09  implement -> review, handoff requested, reason=phase_changed
12:50:09  TaskLeaseRenewed      agent idle, pane capture unchanged
12:52:15  pane input confirmed  the single resend, 4m06s after the request
12:52:39  TaskLeaseRenewed
12:55:14  TaskLeaseRenewed
12:57:49  TaskLeaseRenewed
12:58:14  TaskReleased  reason=handoff_unanswered  failure_class=handoff_unanswered
          last_error="handoff requested (phase_changed) and unanswered for 10m5s"
12:59:14  TaskLeased    successor leases normally
13:02:20  TaskSubmitted

Every assertion the operator set for this rung held:

  • the release does not look like an ordinary lease_expired: it carries reason=handoff_unanswered and lifecycle_phase=handoff_unanswered;
  • the idle-renewal rule did not kill the wait: four renewals on an idle pane with no capture movement, each one the gate would have refused before this commit;
  • the resend preserved the reason the request was first made with;
  • exactly one resend, not one per tick;
  • the timeout produced the class at 10m5s, one tick past the bound;
  • nothing stale was left: releases: {}, no session, no lease, and zero tmux panes matching the id;
  • the next attempt leased 60s later and reached TaskSubmitted.

The debt projection classified it with no new code beyond the class mapping:

v1:operational:handoff_unanswered:workpc-claude:lease   operational  important
  "handoff requested (phase_changed) and unanswered for 10m5s"

Inference, not observation. The resent prompt's text was not captured. That it carried the phase_changed wording rests on the code path and the timing, not on a pane capture. A future rig should publish a capture across the resend.

The implement-successor rung missed again, and why

A five-phase plan does not lengthen the implement phase. This one ran 12:45:18 to 12:48:09, about 2m50s, with verifications 20 to 40 seconds apart:

12:45:47 phase-1   12:46:07 phase-2   12:46:32 phase-3
12:47:12 phase-4   12:47:52 phase-5   12:48:09 implement -> review

The brief's explicit one-phase-at-a-time requirement was followed, and it still took under three minutes. Phase count is not the lever; volume of mechanical work is.

awaiting_manual_verification does not hold the phase open either. All five phases carried that status and the agent moved on regardless.

The state-file lever cannot be driven with restart alone

The documented procedure is stop, edit, start. The unattended sudo rule grants install and systemctl restart orchestra-worker, with no stop or start. With restart only, the running worker's five-second tick save landed between the edit and the restart, and the edit was gone. Widening the sudoers line is the fix.

One cosmetic note

Session.HandoffRequestedAt serializes as 0001-01-01T00:00:00Z when unset, because omitempty does not omit a zero time.Time. The worker parses it back to a zero value and watchHandoff starts the clock, so behaviour is correct. It is the same marshalling trap the operator console hit.

Run 18, 2026-08-29: the implement-successor rung, proven

Task 06G4VF5HZW7Q4JBM3TTY7W1Y64 on c587f2c, a five-phase plan whose phases are ten named checks rather than one refactor. Volume of mechanical work is what keeps the implement phase open; phase count does not.

The rotation

13:43:10  implement session launched
13:43:44  phase-1 verified
13:44:04  phase-2 verified
13:44:35  handoff requested, reason=milestone, delivered by watchHandoff's resend
13:45:25  TaskLeaseRenewed
13:46:51  TaskReleased   handoff_ref 0b4fae6f705a  anchor 8ea3c7d1ae8f
13:46:51  TaskLeased     successor, same harness
13:46:55  TaskPickupValidated
13:47:06  TaskLaunchAcknowledged, still in implement

The lever was the worker state file, backdated past handoffRetryAfter so the request went out on the next tick. Under c587f2c that lever now produces a real prompt: the flag alone used to sit there unasked, which is how run 16 ended in a silent expiry.

What the successor was given

From .orchestra/launch.md, written at 13:47:05:

## Current phase
implement: Implement the accepted plan below. Verify as you go.

## Verified git state
- head: 8ea3c7d1ae8fd960897344694645117adbd8ee82
- uncommitted changes: false

## Current human decisions
None recorded. Work from the goal and acceptance above.

## Plan progress
Orchestra established this by running the plan's own verification. You cannot
write it.
- phase-1 (...): automated checks passed at d3acd4989a20, stale because the
  tree is now at 8ea3c7d1ae8f, waiting for the human to confirm the manual steps
- phase-2 (...): automated checks passed at d3acd4989a20, stale because the
  tree is now at 8ea3c7d1ae8f, waiting for the human to confirm the manual steps
- phase-3 (Checks 7 to 8): not started
- phase-4 (...): not started
- phase-5 (...): not started

Everything the rung asked for is there: the complete accepted plan with all five phases, their files, changes and verification; the phases already verified; the first unfinished phase; and the current human authority. The launch context also carries the accepted research and its dead ends.

SHA staleness renders itself. Neither phase is reported as simply passed. Each says the checks passed at d3acd4989a20 and are stale because the tree has moved to 8ea3c7d1ae8f. That is half of the manual-verification rung observed without being asked for.

The lever still loses a race, sometimes

systemctl restart alone leaves a five-second window in which the running worker's tick save can clobber the edit. It clobbered run 17's attempt and survived run 18's. Stop, edit, start is the reliable sequence, and it needs systemctl stop and start in the unattended sudo rule.

Run 19, 2026-08-29: the manual verification seam, and F63

Two tasks, both ingested from Gitea so that comments reconcile: 06G4W2TMZ9MW9SEBM5MGTM5288 (issue 24, plan 500464b9) and 06G4W63545T3RV8SSKGKN11B3G (issue 26, plan 53f7918b).

A burn-in task cannot test this rung. Comments reconcile only for the source the task came from, so a task created with source: "burnin" has no comment stream at all.

Proven: the keyed seam

15:09:15  PlanPhaseVerified phase-1  awaiting_manual_verification  at 80728967
          comment: "Looks good to me. Nice work on this one, ship it."
17:10:03  HumanDecisionRecorded  subject=operator_instruction
          phase-1 unchanged, still awaiting_manual_verification
          comment: "orchestra verify phase-1"
17:10:25  HumanDecisionRecorded  subject=plan_phase_verification:500464b9…:phase-1
          phase-1 verified

All five phases reached awaiting_manual_verification on their automated checks alone. A generic approval satisfied nothing. The keyed form flipped exactly the phase it named, under the plan ref Orchestra supplied rather than one the commenter chose.

Proven: re-verification at a new SHA

On issue 26, inside one live implement lease, with the worker stopped for the whole edit:

15:23:00  PlanPhaseVerified phase-1  awaiting_manual_verification  at f5d8f90b
          operator commit moves the tree  f5d8f90b -> 1f07b89c
          .orchestra/plan-progress.json requests phase-1 again
15:23:17  PlanPhaseVerified phase-1  awaiting_manual_verification  at 1f07b89c
17:23:16  HumanDecisionRecorded  subject=plan_phase_verification:53f7918b…:phase-1
          phase-1 verified at 1f07b89c

The rerun ran the plan's own commands at the new tree and wrote a fresh record bound to it. The worker never reads a command out of the request: the request names a phase and nothing else about it is trusted.

F63: a manual sign-off outlives the tree it was given against

The second half of the same rig, with no new comment:

          operator commit moves the tree  1f07b89c -> 28af84ff
          .orchestra/plan-progress.json requests phase-1 again
15:24:02  PlanPhaseVerified phase-1  verified  at 28af84ff

RecordPlanPhaseVerification consults manuallySignedOff (internal/operations/planprogress.go:127,152), which asks only whether a decision with that subject exists. It does, forever, for that plan ref and phase id. So the automated half of a phase is re-established at every new SHA while the manual half is asserted from a tree the human never saw.

This is the same class of bug AtSHA exists to prevent, applied to the half that AtSHA does not cover. A manual check on this project is a human reading printed output; an edit between the sign-off and the rerun can change exactly that output.

Only a replan clears it, because PlanPhases() drops records whose plan ref is not the accepted one.

Proposed shape, not built: when the reducer flips a record on a keyed decision (internal/store/store.go:273), it knows the record it satisfied and that record's AtSHA. Stamp that sha on the decision, and have manuallySignedOff require it to match the run being recorded. A sign-off that arrives before any run has no sha and keeps today's behaviour, so the ordinary ordering is unchanged.

Also established

  • review -> implement is legal in legalPhaseTransitions, but AdvanceWorkPhase only walks the project's path forward. A task that has left implement can return only through a review that requests changes, not through the operator phase endpoint.
  • The agent's re-verification request is a file an operator can write: .orchestra/plan-progress.json, {"phase": "...", "status": "ready_for_verification"}. It is the whole lever for this rung.
  • Ingest turns every issue label into a required capability, so a rig issue must carry no labels or it will not schedule.

Run 20, 2026-08-29: the mismatch and replan ladder, and three defects

Task 06G4WJ9T4F35NZC4Z8QQXM9Z6G (issue 29) on de18f37, plus a first attempt on 06G4WDJ06G7FK00QF73Z9DDBT4 (issue 27) on c587f2c that found F65.

The lever throughout is the agent's own file, written by the operator with the worker stopped: .orchestra/plan-mismatch.json, carrying phase_id, observed, contradicts, evidence and requested_action. The worker fills plan_ref and at_sha from what it can verify, never from the file.

F65: a task that stops for a human never stopped

Found on the first attempt, live:

16:04:25  PlanMismatchRecorded  phase-2  human_decision
16:04:25  worker: plan mismatch refused: task version conflict
16:04:48  PlanPhaseVerified phase-1     work continues as if nothing happened

Store.Append fences every lifecycle event on a leased task against the current owner and epoch. blockForPlanMismatch carried neither harness_id nor lease_epoch, so the block was refused on every task that was actually running, which is every task that can produce a mismatch. The observation was durable and the transition silently did not happen: the repo's own documented failure shape. raiseTrajectoryGate had the identical omission, so the trajectory gate could never have stopped a live task either.

RecordPlanMismatch also returns that error after the record is already appended, so the worker told the agent its report was refused for a report Orchestra had accepted.

Fixed in de18f37 with one fenceToLease helper, called by all three stops. The human-decision path already did it correctly and explained why in a comment; that comment is now the helper.

Why no test caught it. planWith never leased its task, so every plan-progress and plan-mismatch test ran in a state no agent can be in. The lease helper in the same package carries a comment warning about exactly that. It leases now, and the mismatch block test fails without the fence with the same task version conflict.

Proven: human_decision stops, and the reply resumes the same plan

16:24:21  PlanMismatchRecorded  phase-2  human_decision
16:24:21  TaskBlocked           plan_mismatch, lifecycle awaiting_human
18:24:46  HumanDecisionRecorded operator_instruction   (Gitea's timestamp)
16:25:16  TaskCorrected         resumed
16:25:16  TaskLeased            plan_ref unchanged at 0bc8ef84
16:25:21  TaskPickupValidated

55 seconds from block to resumed, on the same plan, with no reseal. The blocker packet states the observation, the plan's claim and the evidence.

This is also the live proof of F64: before 6cb2f93 nothing read a blocked task's comments, because the reconciler ran only before a lease and at a turn boundary, and a blocked task is never leased.

Proven: replan keeps the old plan until a replacement seals

16:26:07  PlanMismatchRecorded  phase-3  replan
16:26:07  WorkPhaseChanged      plan     reopen=PlanMismatchRecorded
          during planning: plan_ref 0bc8ef84 accepted, phase-1 and phase-2 counted
          after the seal:
            plan_ref     922f293b
            plan_history [0bc8ef84]
            progress     empty
          successor in implement: four phases, all "not started"

The successor's launch context carries the replacement whole, with files, changes and verification per phase. The old progress stops counting because PlanPhases() drops records whose plan ref is not the accepted one. An abandoned replan would have cost nothing.

F66: the planner replans blind

The planning session convened by a replan is given the ordinary plan brief and an empty template. Its launch context says nothing about the plan that already exists, the contradiction that reopened the phase, the phase id, the evidence, or the fact that its output supersedes an accepted plan. The only human input it carried was an unrelated earlier correction.

Everything it needs is already durable. PlanMismatchRecorded holds the phase, the observation, the contradiction and the evidence, and reopenPhase writes reopen_phase_id onto the WorkPhaseChanged payload. None of it reaches the agent, so nothing stops the replacement from sealing with the same contradiction in it.

Smaller observation

The implement session kept working after the reopen. It verified phase-2 against the old plan 40 seconds after the task had moved to plan. Harmless, because supersession discards it, but the reopen does not end the session that reported the contradiction. That session rotates at its own next boundary.