Files
ersatztv/docs/handoffs/orchestration.md
T
timothyandClaude Fable 5.1 14e075174b
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 7s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 13s
PR Gates / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / decisions lifecycle (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ 14e0751 (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 14s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 18s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 15m39s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m7s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m41s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
fix(911): in a resume, the existing PR's stale head is the expected state until the push, not a finding
Three consecutive #554 resume rounds graded "PR #910's head is still the
first commit" as blocking; the push deliberately follows the loop. The
reviewer brief now says so when args.pr is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QqCpYFsKgnAnx6jVwrKiV
2026-09-05 03:06:02 +02:00

11 KiB
Raw Blame History

Orchestrated sessions

How one session closes several issues at once: an orchestrator that never implements, subagents that each own one issue in one worktree, and the repo's merge gate between every branch and main. This page owns the mechanics (process.orchestrated-session). orchestrator-prompt.md is the standing prompt that starts such a session; chicorytv-issue-queue.md still owns queue selection, claiming and the HARD CONSTRAINTS, and nothing here relaxes them — an orchestrated session is several single-issue sessions run by one referee, and each of them is bound by the whole kickoff.

Roles

Role Model, effort Does
Orchestrator Fable or Opus, high Reads verdicts and evidence, ticks the ## Done-when boxes, posts the H10 verdict, merges through the consent hook, clears labels, removes worktrees, watches for collisions and hangs. Never picks, claims, codes or pushes.
Picker + 2 refuters sonnet, medium Apply scripts/select-queue.sh and the kickoff's claim and bundle rules to live Gitea state; return up to count mutually non-colliding picks. .claude/workflows/ersatztv-pick-next.js, args {taken, closed, notes, count}.
Recon (large issues) Opus, high Reads the docs the task-signal map names, finds the handlers, call sites, tests and guards, returns a plan.
Implementer Opus high; sonnet medium for size: small Claims, builds, rebases onto origin/main, runs the local gate, sits inside the review loop, supplies the evidence per Done-when box.
Reviewers correctness: Opus high, in its own worktree; conformance: sonnet high; cross-family: Codex via codex exec for the rubric's risk classes, with a cold Opus review-only fallback when Codex cannot run Run the gate themselves; blocking / should-fix / nit.
Fixer same tier as the implementer Answers findings; two rounds, then the orchestrator decides.
Finisher sonnet, medium The single push, the PR, the closing record. Ticks nothing.

Sizing is a rule (process.per-agent-model-routing): every launch names its model and effort. sonnet at medium for picking, refuting, small well-specified fixes and mechanical finishing; sonnet at high for conformance review; Opus at high for recon, implementation, fixing and correctness review; Fable for orchestrating and for the frontier escalations the kickoff lists, never for implementing. Cross-family review is mandatory for locks/concurrency, auth/security, API write-path handlers, DB migrations and diffs over ~150 changed C# lines (process.independent-review-rubric); the picker's risk field decides. When Codex cannot run, the workflow substitutes a cold same-family review-only agent and the PR body states the substitution and its reason, as that record requires.

.claude/workflows/ersatztv-issue-build.js runs claim → recon → implement → gate → review → fix → land for one issue or bundle (args: issues, slug, title, size, risk, area, needs_e2e, port, body_summary, done_condition, files_likely, avoid, trailer; issues, trailer and port are required and the script refuses without them). ersatztv-resume-branch.js finishes or fixes a paused branch from a JSON brief. Both are passed to the Workflow tool inline; the tool persists the script and returns a path for later runs.

Isolation

  • One worktree per issue: ~/orca/workspaces/ersatztv/wt-<n>, branch <n>-<slug>, cut from a freshly fetched origin/main (process.shared-tree-readonly), by absolute path. Never under /tmp — macOS purges it mid-session — and never the shared checkout.
  • The commit guard (pretooluse-worktree-guard.sh) reads a .claude-worktree-owner marker that posttooluse-worktree-marker.sh writes on git worktree add. Measured 2026-09-04: a workflow agent's git worktree add writes no marker (project hooks do not run for a workflow's agents), and a ~-prefixed path from the session writes none either, so orchestrated worktrees carry no marker and the guard fails open inside them — acceptable, because they all belong to one session. A sibling session's worktree keeps its marker and its denial (process.foreign-worktree-plumbing-merge), so a denial inside an orchestrated worktree means the worktree is foreign: the scripts stop and report it, and never overwrite the marker.
  • web/node_modules is per worktree: clone it from the shared checkout with cp -Rc when the lockfiles match, otherwise npm ci. The shared copy is kept current by scripts/refresh-shared-checkout.sh at session end.
  • Three issues in flight, not five: every implementer and every worktree-isolated reviewer runs a dotnet build and a web build, and the cap is 34 concurrent builds gated on free RAM (process.build-concurrency-limits). The Agent hook enforces the RAM gate for agents launched by the Agent tool, not for a workflow's agents, so the orchestrator holds the count itself.
  • Live-E2E runs per worktree on the slot's own port. scripts/e2e-local.sh refuses concurrent runs within one repo root because each run re-copies that root's wwwroot; across worktrees there is nothing shared but the ports, so every slot gets a distinct port argument, one run at a time inside the slot, and the launcher's pre-flight refuses a busy port and names the holder. A busy port is reported, never taken over; kill only the PID the launcher printed to you (testing.e2e-cleanup-scope-by-pid).
  • Reviewers create scratch only under /private/tmp, delete nothing outside it, and never build a path with .. segments. A reviewer that must build gets isolation: worktree and fetches the unpushed branch into it (git fetch <worktree> <branch> && git checkout --detach FETCH_HEAD); it never builds in the slot's worktree.

Landing a branch

The order is the kickoff's (process.pr-routine-sequence, process.local-gate-before-push), with the review loop inside the worktree, before the single push:

  1. Implementer: rebase onto a fresh origin/main, then the local gate — dotnet build and dotnet test on the touched test projects (the full solution for anything under ErsatzTV.Core), npm run check:api && npm run lint && npm run typecheck && npm run build && npm test under web/ for SPA changes, PYTHONPATH=. python3 -m pytest scripts/tests -q plus ruff for anything under scripts/, .claude/, .husky/ or .gitea/, the BOM check for touched .cs (process.bom-format-detection-recipe), and live-E2E on the slot's port for a write path or UI change (testing.live-e2e-prepush-timing).
  2. Reviewers read the worktree diff (git diff origin/main...HEAD) and run the gate in their own worktrees. A round carrying any blocking or should-fix finding goes back to the fixer regardless of the lens's own verdict word (a should-fix is a real defect by definition); nits alone end the loop. A round in which every lens failed is an error, never a clean round. After two fix rounds an open finding stops the workflow before the push and the orchestrator decides. The same filter applies to the post-rebase round on the pushed head. In a resume, the existing PR's stale head and body are the expected state until the finisher pushes, never a finding. The loop ends on a clean round, never on "round one's findings are fixed".
  3. Finisher: fetch; if origin/main moved, rebase, re-run the gate, and compare the patch-id before and after — a changed patch (a conflict resolved, an artifact regenerated) sends the branch through one more review round before the verdict, because the reviewed tree must be the pushed tree. Then one push, git push -u origin <branch>; open the PR with fixes #N for every issue in the bundle, the review history the workflow recorded (one line per round, quoted verbatim — the finisher never describes a fix it did not see), the cross-family review status, and the session trailers; arm the CI monitor on the head sha; post the ## Closing record on each issue with the evidence for every ## Done-when box, ticking none.
  4. Orchestrator: read the review evidence, not the summaries. Send the PR back for anything that lets a route or test pass having done nothing. Tick each box whose evidence holds, then re-read the head sha immediately before posting scripts/post-review-verdict.sh <pr> MERGEABLE <note> naming the reviewers, the rounds and the workflow's returned cross_family status (a Codex failure in a post-rebase round is in that return, not in the PR body); tick the review box. Wait for CI — a cancelled job reads as failure at the combined status endpoint, so resolve it via the run's jobs (ci.cancelled-is-not-a-verdict).
  5. Merge through the Gitea merge tool with the full head sha; the consent hook derives consent from the ticked boxes, the sha-bound verdict and green CI and auto-grants (release.merge-consent-autogrant). A merge that asks is a state the orchestrator could not derive, so derive it — never answer the prompt from memory. Then DELETE /issues/{n}/labels/100 on each issue, git worktree remove, refill the slot.

A rebase changes the head sha and voids the verdict; a fix after the push is a new commit, never an amend (process.pr-routine-sequence). A branch that conflicts after its push goes back to the fixer for a rebase pushed with --force-with-lease — the one sanctioned rewrite, scoped in process.orchestrated-session — another gate run and a fresh review of the rebased head. Push a branch behind origin/main and the pre-push hook refuses it (release.format-as-you-touch-rebase).

Resuming a paused branch

A branch with a PR whose verdict is stale (head moved), whose CI is red, or that Gitea reports unmergeable is resumed by ersatztv-resume-branch.js with a brief holding the done condition, the last findings and the orchestrator's context. Its fixer rebases first, re-runs the gate, sits in the same review loop, and the finisher pushes with --force-with-lease; the orchestrator posts a fresh verdict for the new head. A resumed session finishes every open branch before it picks anything new.

Hangs and incidents

  • A dotnet test or E2E run with no output for ten minutes is a hang, not a slow test. Kill it by the PID you started, gently; re-run once before diagnosing.
  • Under an API incident (https://status.claude.com/api/v2/status.json), mechanical finishers and probe-driven reviews run on whichever model is healthy; large implementation waits. Back off before retrying.
  • A workflow that returns an empty or unexpected result is read from its journal.jsonl before it is re-run; a resumed run replays the unchanged prefix from cache.
  • Before any stop: the H12 audit (process.issue-qualification-audit), scripts/refresh-shared-checkout.sh (session.shared-checkout-refresh), and a handoff memory naming every open branch, its worktree, its PR and its next step.