lithos-loom develop converge — reference¶
Pulled from the lithos-loom repo
This page is imported at build time from docs/cli/converge.md on main. Edit it there, not here.
Converge an existing PR to review-green. converge runs the story-develop reviewer panel + deterministic gate against a PR, and if that blocks, runs a coder fix loop on the PR branch — implement→review→fix rounds until the panel LGTMs and the gate floor is clean — then fast-forward-pushes the fixed branch back to the PR head, ready for the human merge gate.
This automates the operator's manual chore — take a review, hand it to the coder, tell the panel to look again, iterate until every reviewer is satisfied — as one command. It realises ADR 0003 §9 "Shape 1" (re-dispatch development on the PR branch without resolving the pr gate) as the on-demand / human-triggered variant. See ADR 0009 and SPECIFICATION.md §4.15a.
converge does not re-implement the develop loop: it runs an intake review (the same primitive develop review runs), then calls develop() with a LoopEntry override so the coder/panel/gate/dispute/stall/termination sequencing is single-sourced with story-develop. Round 1 is a cold-start turn that reconstructs the PR author's intent before changing anything.
TL;DR¶
# Converge a PR with the standard panel, then push the fixed branch
lithos-loom develop converge #142
# Thorough panel, codex as the coder, cap the loop at 4 rounds
lithos-loom develop converge #142 --profile thorough --coder codex --max-rounds 4
# Converge locally without pushing (inspect first)
lithos-loom develop converge #142 --no-push
# Supply the intent explicitly (overrides the PR body) and save the JSON summary
lithos-loom develop converge #142 --ac "the leak must close the handle on error" --json /tmp/c.json
What it does¶
- Resolves the PR to a
base..headpair, the pushable head branch, a fork flag and a merged flag (via the typed GitHub client — the same seamdevelop reviewuses). Two PRs are refused here, before any container runs. A merged PR →merged: it has already landed, so there is nothing to converge and no fix commit pushed to its branch could reach the base. (Reviewing a merged PR stays legitimate —develop reviewdoes not refuse one; only converge, which pushes, does.) A fork PR →fork_unsupported: loom pushes under origin credentials and cannot push to a fork. The merged check runs first — "push it from your fork" is useless advice for a PR that already merged. - Intake review. Runs the resolved profile's deterministic gate + the reviewer panel once at the PR head, under a distinct
run_id(<run_id>-intake) so its round-1 artifacts never collide with the fix loop's. If the panel is incomplete (interrupted / invalid) →failed(nothing trustworthy to seed the loop from). If the intake spend alone meets--max-cost→failed(checked before the clean/blocking split, so a clean intake can't bypass the budget). If it does not block →already_clean: no coder is built, nothing is pushed, exit 0 — this reports on the PR snapshot resolved before intake, not a live re-check, and is the cheapest path for the common re-check. - Fix loop. If the intake blocks, enters
develop()on a committable worktree at the PR head (base = the PR merge-base, tracked against the PR's live base ref so a base merge during the run moves the diff base with it — PRD S5c), seeded from the intake findings + the PR's own commit log. Round 1's coder is a cold-start turn: "you are picking up a PR you did not author — read the acceptance criteria, the commit history, and the code to reconstruct intent, then address the findings to satisfy that intent; dispute (don't silently comply with) a finding that undoes a deliberate decision." Rounds ≥2 are the normalcoder_fixpath. Termination isdevelop()'s own —approved/disputed/stalled/cost_exceeded/max_rounds. Story-develop's cheapneeds-decisionescalation (SPECIFICATION §5.5) is deliberately not part of it: it ends in a loomhumangate on the story whose brief is the coder's question, and converge has no such surface — every unapproved run flattens tonot_converged, which is what both watcher consumers branch on. Converge therefore leaves that paragraph out of the coder's prompt and builds its ledgers with the escape off, so aneeds-decisionmark is recorded as the ordinarydisputedit also is and the two-round dispute guard bounds it. - Push epilogue. On approval (and unless
--no-push), pushes the fixed branch onto the PR head ref only if the PR head is still exactly the resolved head — an atomic lease (git push --force-with-lease=<ref>:<expected>) plus a local append-only ancestry check. A head deleted, advanced, or force-rewound mid-run is refused asmerge_race(never silently recreated or overwritten), while a successful update stays a pure fast-forward — not a blind--force. A fork ref (absent on origin) is refused; auth / hook / branch-protection failures stay generic errors.
Three intake modes¶
Default (local panel). converge converges against loom's in-container codex/claude panel + check-floor — fast, local, no GitHub round-trip. The intake reviewers are cold by design (no coder-summary to anchor on); only the fixer is given the PR's intent.
--from-github (external findings — PRD S2 slice B). Instead of the local-panel intake, converge ingests the PR's external review material — reviews, inline comments and Conversation-tab comments left by bots or humans (the conversation is the only channel open to the PR's own author, so the operator's verdicts on loom-delivered PRs arrive there — #353):
- Trust line (ADR 0011 d8): allowlisted bot logins (
[github_watcher] trusted_bots) and humans with write/admin on the repo may seed the coder. Everyone else's findings are printed (untrusted (reported only)) and never placed on a prompt path. Roots already proven handled by an authenticatedFixed in <sha>reply are skipped. - Triage (S5a): one cheap read-only container turn checks each claim against the code before any fixing. It may REJECT only with cited evidence — a
file:linecitation resolving to a real tracked file in the repo at the reviewed commit (a bare filename, dotted version token, orHTTP:404-style code is not one; line-existence and content are deliberately NOT checked — semantic triage quality is the S8 eval fixtures' job, not the parser's); anything short of that — including a failed triage turn — proceeds (actioning a false positive is recoverable; suppressing a true one is not). All findings rejected → statustriage_rejected, exit 0, no coder built (reported, not remediated: the watcher refunds the S5b round once per budget, as foralready_clean). A third verdict,NOTHING_TO_REMEDIATE, is for material that is not a claim at all — a pure approval ("No findings. Ready to merge."), which reviewers leave on the same channels they review on. It cites nothing (there is nothing to refute) but must state a reason — that line is what the operator reads, so a bareNOTHING_TO_REMEDIATEproceeds like any other unevidenced verdict — and it is honoured only for an eligible row: one whose own body carries recognised approving words in the author's own voice and which is not a blockingCHANGES_REQUESTEDreview (decided in code from the batch, not from the verdict sentence, so a claim carrying no approval — "this leaks the token" — can never be dropped as one, and neither can one whose only approval word sits in a block quote, a code span or fence, an indented code block, a strike-through, an invisible HTML comment, an enumeration of values (a list, or a GFM table recognised by its delimiter row), a dotted identifier named in prose, the lazy continuation a quote, a list item or a table swallows, or the far side of a hard wrap — the floor reads the paragraph GitHub renders, not the author's line breaks). It injects nothing and answers no thread; a batch whose every finding is one ends the runalready_cleanat round 0 — the #380 outcome without the coder turn and panel pass remediation run 827cedf8 spent to learn what the comment already said. It is not a cheap REJECT: an approval that also asks ("LGTM, but rename X") PROCEEDs on its ask, andevals/triage'sapproval-and-askfixture measures both halves (a must-proceed answered this way scores as over-suppression). - Injection: surviving findings seed the coder directly via the same
LoopEntryseam, bypassing the local intake — dispatching converge on an external finding without injection would returnalready_cleanfrom the very panel that missed the defect (ADR 0011 d1/d7). The loop's own panel and check-set then judge the result: the external reviewer proposes, loom's gate disposes. - Thread replies: after the run, each comment-backed finding gets a reply —
Fixed in <sha>only when the coder explicitly acknowledged that finding asFIXEDin its FINAL handoff's mandated## External findingssection, the loop approved, the final tree differs from the PR head outside the project's generated paths, and the branch was actually pushed (loop approval alone is tree-level evidence, so an id the coder omits staysunaddressedand its thread gets no reply — a silent partial fix can never earn a false claim; the acknowledgement is mandated in EVERY coder round and the verdicts describe the TREE, not the round's work —FIXED= a fix is in the tree, whichever round made it;NO CHANGE NEEDED= never a defect, nothing was ever changed for it;REVERTED= a fix undone — and the epilogue reads every round's: a decisive final ack (FIXED/REVERTED/DISPUTED) stands on its own, so a fix a later round undid isREVERTED— #387, lens #84's falseFixed inover a net no-op — while a finalNO CHANGE NEEDEDover round 1'sFIXEDrestated unbroken through every round between is read as round 1'sFIXED(round 1 predates the panel, so only its section is trusted to speak of the external ids — a later round'sf-001line may describe the panel's ownf-001, as lens #87's rounds 2-3 did), any other disagreement — a later-round origin, aREVERTED/DISPUTED/ omission in between — isunaddressed(no thread answered, a falseFixed inbeing the one unacceptable outcome), the reading recorded in the outcome'snote(#399, lens #87's falseNot changedover a round-1 fix); an id the final handoff omits staysunaddressedwhatever an earlier round said; an approved run whose final tree equals the PR head outside the generated paths is read as reverted whatever the handoff says — answeredNot fixed — a change was made and then reverted: <why>naming the operator decision, the run notsucceeded, exit 1, and — under the watcher — adisputedhuman gate),Not changed — triage: <evidence>for rejections, the coder's reasoning for disputes,Not changed — no change needed: <why>for a finding the coder judged not a defect. Summary-only reviews (no inline comment) have no thread and appear in the rendered summary instead. A conversation-comment finding is answered with a conversation comment ending in_(replying to <comment url>)_; a landedFixed in <sha>one by a write/admin author proves that comment handled on later fetches and sweeps, exactly like an inline thread reply. - A finding written against an older head sha is injected with a re-anchor note (verify before re-fixing); severities enter at
minor(external reviewers state none).
--resolve-conflicts (conflict convergence — PRD S5, the CLI half). The merge is the intake. In a throwaway worktree at the PR head, converge merges the base's current tip with --no-commit; a clean merge (or a head that already contains the tip) exits no_conflict at once — no agent runs, the worktree is removed, the base-move re-gate (merge-gate) owns that case. Text conflicts only: a conflicted path that is binary, a symlink, a submodule, deleted on one side (modify/delete) or otherwise carries no textual markers is refused at intake — conflict_unsupported, exit 1, the paths and reasons named, no agent run, worktree removed — because nothing could distinguish a coder's resolution of such a path from an untouched one, and the round commit's git add -A would silently take whichever side git left in the tree (the coder cannot stage: the sandbox's git dir is read-only). A text conflict is left in progress (MERGE_HEAD set, the auto-merged paths staged, the conflicted paths carrying markers) and round 1's coder gets resolve_coder_init.md with a brief: the conflicted paths, the PR's title + body, the commits landed on the base since the merge-base, and the conflicted hunks (bounded per path). The coder resolves the marked files honouring both sides (a generated file is regenerated, never hand-merged) and never touches git state; the round commit then is the merge commit (two parents: the PR head, then the base tip). A pre-commit guard is the host-side enforcement behind that rule: it refuses a tree still carrying markers, any unmerged index entry outside the text conflicts the coder was given, a merge in progress of anything but the intended base tip onto the PR head, and — once HEAD has moved — any tree the intended base is not an ancestor of; the round fails and nothing is gated, reviewed or pushed. The project's check-set and the panel then judge the composed tree: the fork point moves to the base tip once the merge commit exists (S5c), so the reviewers' diff shows the PR's own changes on the new base — and because a conflicted path resolved to the base's version is absent from that diff, every round's reviewer prompt also carries a merge context naming the conflicted paths and both parents with a per-side diff to run (git diff <pr head> HEAD -- <paths> / git diff <base tip> HEAD -- <paths>), so a resolution that silently dropped the PR's behaviour is inspectable. An approved run pushes the merge commit onto the PR branch (append-only, ancestry-proved) after one more proof that the intended base is an ancestor of the approved HEAD (a loop that approved a tree without the merge is failed, nothing pushed); an unapproved one is not_converged like any other. --base <ref|sha> names the merge target instead of the PR's base (an explicit sha merges that exact commit). Exclusive with --from-github. The JSON summary carries conflict: {paths, base_ref, base_sha} (null on the other modes). The watcher half (SPECIFICATION §2.2 "Conflict resolver") runs this mode autonomously on a delivered PR whose base-move re-gate recorded a conflict — once per sha pair (reserved on the gate before the spawn, pinned with --expect-head / --expect-base), [ConflictResolved] on a pushed merge commit, a needs-human gate otherwise; host dial [github_watcher] conflict_resolve_enabled, per-project opt-out develop_conflict_resolve = false.
Acceptance criteria (the reviewer's + fixer's brief)¶
Precedence: --ac-file > --ac > the PR body. A PR with no body and no --ac / --ac-file is rejected — a reviewer with no criteria is near-useless. converge requires a PR, so a bare range / branch is rejected up front (use develop review for a read-only review of an arbitrary range).
Flags¶
| Flag | Meaning |
|---|---|
<pr> |
The PR to converge (positional): #142 / 142 / a PR URL. A range / branch is rejected. |
-p, --profile |
Review profile — selects the persona panel + check-set (default standard). |
--reviewer NAME |
Override the panel personas (repeatable). |
--ac TEXT |
Acceptance criteria text. |
--ac-file PATH |
Read acceptance criteria from a file (wins over --ac). |
--base REF |
Override the diff base (default: the PR merge-base); with --resolve-conflicts, the merge target (a sha merges that exact commit). |
--check-command NAME=CMD |
Override a gate check's command (repeatable), e.g. --check-command typecheck='make typecheck'. Runs the repo's own command verbatim — raw exit code, no uv-wrap, no tool-probe, and no finding-adapter rewriting even for ruff/bandit/pip-audit (so an overridden check opts out of structured findings) — instead of the catalog default that over-scopes vs the repo's real policy and forces extra fix rounds (bare uv run pyright scanning a test tree full of pre-existing type debt). Overridable: lint / typecheck / sast / dep-audit / coverage / semgrep (the test check uses --test-command; format is the autoformat pass). An unknown / non-overridable check fails closed. |
--check-state NAME=STATE |
Override a gate check's blocking state (repeatable): required / informational / off, e.g. --check-state sast=off. Beside --story, merges per key over the story's develop_check_states table (never a whole-table replace); --check-command likewise over develop_check_commands. off drops the check cleanly (runs nothing, records nothing) — distinct from a required-but-tool-absent check, which still blocks. Generalizes --no-test-gate (--check-state test=off). Stateable: lint / typecheck / test / sast / dep-audit / coverage / semgrep (format is the autoformat pass). Wins over the legacy test_gate for the test check. |
--expect-head SHA |
Refuse before anything runs unless the PR's current head is this sha — an autonomous caller pins the run to the head it authorised (PR #366); with --json a mismatch writes {status: "head_moved", expected_head, actual_head} and exits 0 (nothing ran; the caller re-keys). |
--expect-base SHA |
With --resolve-conflicts: refuse before any agent runs unless the base's current tip is this sha (base_moved, exit 0). Together with --expect-head the run can neither spend nor push on a pair other than the one the watcher keyed on. |
--expect-repo OWNER/NAME |
Refuse to act unless the checkout's origin is this repository — compared before any fetch; a mismatch exits 2. A PR number resolves against the checkout, so the watcher's remediation dispatcher pins every run to the repo its gate names (after its own cheap origin read, which refuses a mis-mapped or unresolvable checkout before any budget round is spent). With --json, a refusal writes {status: "repo_mismatch", expected_repo, actual_repo, message} so the dispatcher refunds the round and re-parks the review trigger. |
--test-command CMD |
Command for the test gate check (overrides auto-detection). The test check has bespoke detection, so it takes this dedicated flag rather than --check-command. |
--parity-command CMD |
The repo's aggregate verification command (e.g. make check), run once as a required repo-parity gate check so the converged tree passes whatever CI enforces beyond the structured check-set (diagram/codegen drift, docs lint, …) — closing "gate-green ≠ CI-green". Runs verbatim (raw exit, no adapter) regardless of ecosystem — the primary gate for a repo the catalog can't model (C/C++). See ADR 0010. |
--generated-path PATH |
Repo-relative path whose content is generated (repeatable; PRD S4). In --resolve-conflicts mode a conflict in these paths is not the coder's: it is resolved to the base's copy at intake and set aside, the coder is handed the real conflicts only, the brief names the paths and the command to run before handing off, and a post-commit pass regenerates them after every round commit (after the format pass) as a story-develop r<n>: regenerate commit — deterministic, whatever the coder ran; the panel's merge context names them as generated, not hand-merged. The pass fails closed: the generator's verdict is a required regenerate check in the round's gate (a red run — non-zero exit or timeout — holds approval, reaches the next coder prompt with its output and is named in the outcome's blocking checks, since the committed copies are the intake's and a project need not have a drift check), — a green run whose output cannot be applied (a path git refuses, a file that became a directory) counts as the generator's red — and a pass that never gets a verdict (export / container runtime) ends the run infra_failed with the host action. A merge that conflicts ONLY there is no_conflict here — the merge-gate regenerates and pushes it. With --story, the story's develop_generated_paths. |
--regenerate-command CMD |
The command that rebuilds every --generated-path (e.g. make diagrams). Required with --generated-path. |
--image REF |
Sandbox container image for the agents and the gate (default ralph-sandbox:latest). converge does not read the project-context doc, so a project pinning develop_image must pass it here — otherwise the gate runs in an image that may lack the tooling its checks need (a browser for an e2e parity command, say) and can never pass. |
--artifacts-path PATH |
Repo-relative dir a gate check writes rendered output to (the project's develop_artifacts_path). Enables the artifact review pass; without it converge reviews the diff only. |
--coder claude\|codex |
Coder engine for the fix turns (default: the config's coder). |
--max-rounds N |
Cap the implement→review→fix rounds (validated ≥ 1). |
--max-cost USD |
Soft phase-boundary ceiling on whole-command spend (intake + loop): converge stops before the next phase once recorded spend reaches it (validated finite and > 0). Not a hard cap — an in-flight turn may overshoot and a same-round approval is delivered even if it crossed the ceiling. |
--test-timeout N |
Max seconds for one gate check run — the test check, other check-set checks, and autoformat (default 900, validated ≥ 1). Raise it for a repo whose non-integration suite exceeds the default; otherwise the gate floor never clears and converge stalls with green reviewers. |
--no-push |
Converge locally but do not push to the PR branch. |
--resolve-conflicts |
PRD S5: merge the base's current tip into the PR head in a throwaway worktree and, when it conflicts, have the coder resolve the marked files; the check-set + panel judge the composed tree and an approved run pushes the merge commit. A clean merge exits no_conflict (0) and spends nothing. Exclusive with --from-github. |
--from-github |
Ingest the PR's external review findings instead of running the local-panel intake (see "Two intake modes"): trusted findings are triaged (S5a) and, if they survive, seed the fix loop directly; untrusted authors are printed but never fed to an agent; thread replies are posted afterwards. |
--repo PATH |
Repository to converge in (default: current directory). |
--json PATH |
Write the structured JSON summary. |
--story TASK_ID |
Resolve the story's develop settings (project doc + task develop_*: rounds, profile, panel, check-set, image, models) exactly as the daemon path does, as the base layer under any explicit flags. The watcher-dispatched run passes it. |
-c, --config |
Host config path. |
Output¶
- Plain-text summary to stdout: the status line, the message, the round + fixer-commit count, and (on a push) the pushed sha → PR branch.
- JSON (
--json): a stable object —status,succeeded(the one success verdict:already_clean/converged/triage_rejected— false when aconvergedrun left an external findingreverted),head_ref,head_branch,base_sha,head_sha,rounds,develop_status,host_action(what to fix on the host when the status isinfra_failed, else""),fixer_commits(only the coder's commits, PR head → HEAD, first-parent — not the PR's original commits, nor any base commits a merge brought in),pushed,pushed_sha,intake_cost_usd(in external mode: the triage spend),total_cost_usd,message,deferred_findings, andexternal_outcomes(external mode: per injected finding —finding_id,author,source,stream,activity_id,reply_mode,thread_url,dispositionrejected/nothing_to_remediate/disputed/fixed/reverted/no_change_needed/unaddressed,detail,note(how a final acknowledgement that disagreed with an earlier round's was read — #399; shown on the story's outcome finding and in the summary, never on the thread);fixedrequires the coder's per-idFIXEDacknowledgement — its final handoff's, or round 1's restated unbroken when the final handoff saysNO CHANGE NEEDEDover it — and the loop's approval and a final tree that differs from the PR head — anything less reportsunaddressed;revertedis a fix a later round undid;no_change_neededis a finding the coder judged not a defect — an approval verdict ingested as a finding, intended behaviour — and, likefixed, it requires the loop's approval. #380: a round-1 coder that commits nothing because every injected id needs no change has made a claim, and the loop admits the empty round for review instead of failing it: the fast checks run on the unchanged head and the panel — given the injected findings as review context, with the coder's acknowledgements named as claims to verify — judges it. When the loop approves and every injected finding is refuted orno_change_needed, the run isalready_clean— reported, not remediated, exit 0, the watcher refunds the round once per budget — never thenot_convergedthat spent lens #83's last budget round and raised a needs-human gate on a mergeable PR. The admitted round is a validation pass, not an entry to the fix loop: a claim the panel rejects (or a red required check the floor holds on) ends the runnot_convergedwith the rationale, so a trigger that asked for nothing never drives paid rounds or pushes unrelated commits onto a delivered PR; the claim staysunaddressed, so no thread is answered "no change needed" on the coder's word alone). - Statuses / exit codes:
already_clean(intake didn't block; reports the pre-intake snapshot — or, in external mode, every injected finding refuted orno_change_neededwith nothing committed and the loop's gate + panel approving the unchanged head, #380, or triagedNOTHING_TO_REMEDIATEat round 0 — an approval-only batch, 827cedf8),converged, andtriage_rejected(external mode: nothing survived triage and at least one finding was rejected with cited evidence; no coder built, rejection replies posted — the message names each disposition, so an approval beside a rejection is not reported as refuted) → 0;not_converged(loop stopped unapproved),infra_failed(the intake fetch of the PR head + base failed — a transport failure (Could not read from remote repository/ a reset / a timeout / anearly EOF/ akex_exchange, matched against git's WHOLE stderr — the reported line is a collapse) is retried 3× with a short backoff first, so a daemon SSH-agent hiccup costs seconds rather than a restart, and loom's own watchdog kill (the timeout elapsing with no answer) is retried like the rest — while the attempts share ONE fetch timeout (the ref-lock retry inside each included, a per-attempt reserve keeping the retries runnable) so a hung origin cannot hold a dispatcher's slot for three of them, and a failure that persists ends the run here withhost_actionnaming git's ownfatal:line — #431; or the coder or a reviewer died on the host — an auth / transport / spawn failure that persisted through its retries — either in the intake panel or in the loop; NOT a verdict on the change, no replies posted,host_actionsays what to fix; the github-watcher refunds the S5b round and re-arms the conflict pair on the next daemon boot, #377),merge_race(PR head advanced remotely), andfailed(incomplete intake panel, or pre-loop spend — intake review, or triage where a finding survives into the fix loop — exhausted--max-cost; a no-survivor batch keeps its round-0 disposition instead, the spend having already happened with nothing paid left to stop) → 1;fork_unsupportedandmerged(the PR already landed) → 2;no_conflict(resolve mode: the base merges cleanly, nothing to resolve — not a success verdict,succeededis false),head_movedandbase_moved(the pinned pair no longer holds; nothing ran) → 0;conflict_unsupported(resolve mode: a binary, symlink, submodule, modify/delete or markerless conflict — a human must resolve it; no agent ran) → 1. Every path and hunk in the coder brief and the reviewer context sits inside a fence sized past the longest backtick run it encloses, so repository content cannot close a fence and become prompt prose.
Intake exceptions propagate. An unexpected error while producing the intake review (e.g. a container crash, a bad config) is raised, not silently mapped to
failed— a traceback is the honest signal for an internal fault.failedis reserved for the expected incomplete-review and budget-exhausted cases.
An exhausted run's rounds — develop converge-push¶
A run that stops unapproved (not_converged on max_rounds / disputed /
stalled / cost_exceeded) pushes nothing, by design: an unapproved push
would put unreviewed rounds on a delivered PR. Its rounds are still
committed, on a local branch in <work_dir>/converge/<run>/worktree.
lithos-loom develop converge-push <run> is the operator's
decision about them: without --yes it reports what those rounds produced (the
gate verdict, the findings left open, the fixer commits) and whether they would
fast-forward onto the PR's current head, writing nothing; with --yes it pushes
them through this command's own append-only seam, answers the reviewers' threads
and records [ConvergePushed] on the story naming what it was pushed with. The
watcher reads that push as a human one, so the remediation budget re-arms.
To make that possible, converge writes a converge block into the run dir's
state.json at intake, before the first paid turn — pr_url, pr_number,
pr_head_branch (the PR's head branch, not the run's own local branch),
intake_head_sha, base_sha, repo, and the --story id when given — and
seeds the run's handoff/ dir in the same step, since that is what every run
lookup recognises a run by and the intake review seeds only <run>-intake's.
A run killed during intake (SIGTERM, exit 143) is therefore still resolvable.
The loop's own exit merges into the same file rather than overwriting it, and
converge merges the whole-command spend in beside the loop's own
(total_cost_usd = the intake / triage turn + the loop; develop() records
only cost_usd) — the pre-loop half first, before the loop writes the
terminal status every reader stops on, so a run killed in between can still be
summed. A successful push is recorded as well (converge_push, by:
converge): approval is not that signal, since --no-push and a raced push end
approved with the rounds still only local. In --from-github
mode the run also records the injected batch (external.json: the id→row map,
triage's verdicts, the surviving ids), which is what the replayed thread replies
are composed from — and the run's own reply epilogue adds each id whose reply
the transport confirmed to that file's replied set, so the salvage
answers only the threads still owed one rather than inferring that from the
run's status (which the loop writes before the epilogue runs).
v1 limit — dispute-all round 1¶
If round 1's coder disputes every finding and commits nothing, the deterministic gate still runs on the unchanged head. Such a round converges only if the head was already gate-green. This is rare (the coder is told to fix, not dispute-all) and acceptable for v1.
Stopping a run¶
SIGTERM — what the daemon sends a dispatched run on shutdown — is turned into SystemExit(143) for every develop subcommand (#407 slice 2a), so the finally blocks that stop the run's sandbox containers and tear down its worktree run on the way out instead of being skipped by the signal's default disposition. The run's outcome is still lost (nothing is pushed, no JSON is written); the daemon refunds the budget round it killed and reaps any container that outlived its owner at the next boot.
Requirements¶
Host-only, like a develop run: docker + the agent CLIs (claude / codex) + gh (for PR resolution and the push). Not part of the hermetic make check.