diff --git a/docs/reference/specs/agent-ship.md b/docs/reference/specs/agent-ship.md index b75ed9ed1..4ab2c9ccf 100644 --- a/docs/reference/specs/agent-ship.md +++ b/docs/reference/specs/agent-ship.md @@ -16,7 +16,7 @@ The coding → review → fix loop to LGTM as one [pipeline](../vocabulary.md#pi 5. **Review rounds run on a pinned head, in the unit's thread.** The review child is an ordinary review run given the pull request and the head to pin (`buildShipReviewTurn`; a re-review round carries the previous round's findings and the dispositions the coding run recorded against them, matched by the runner with an id the review never issued named as dropped, item 6). It runs in the unit's thread, like every child of the unit ([record 0055](../../decisions/0055-a-unit-has-one-thread-and-a-round-reads-the-checks-at-its-head.md)): it seeds from its own session (`:review`, [session-log.md](session-log.md)) and attaches a read-only tree of its own life, released like every run's at its end, so nothing needs a second thread; a run live in the unit thread answers the spawn `busy`. A row a bot wrote before that record names a review thread (`reviewThread`, [run-history.md](run-history.md) item 50); its review rounds stay there. The head is pinned before its attach (readonly), the run submits the structured verdict with findings, the reviewed-head gate + repush-void machinery apply unchanged, and the review posts pinned to the head it read. A [verdict](../vocabulary.md#verdict) always covers the full diff against base — the `re-review-delta` skill narrows *reading*, never verdict scope. **No verdict = abort**: a review child whose record carries no `submit_verdict` (budget, refusal, stop) ends the unit with a report naming that terminal — it is never converted into a `request_changes` the reviewer did not make. 6. **Findings and dispositions are typed artifacts.** `ReviewVerdict.findings[]`: `{ id, severity: blocking|major|minor|nit, file, line?, title }`, validated fail-closed per finding; `buildReviewPostBody` renders them under the verdict line, and the body starts with the exact `LGTM:` token only for `approve` — an `approve` carrying a finding at or above the severity to address is downgraded to `request_changes` in `parseVerdictInput` ([agent-review.md](agent-review.md) item 5a), so the auto-approve workflow can never fire over a finding the loop has to address. The findings step's coding run records one disposition per finding through `submit_dispositions` (`fixed|declined` + note). Dispositions are kept per round: finding ids are only unique within one review round, so a later round reusing an id for a new finding inherits nothing, while a finding carried forward unchanged (same id, severity, file, title) keeps its recorded disposition. The tool records what the run submits, always: every dispatched run has the sink, the tool holds no list of the review's ids, and the last set rides the run record ([run-history.md](run-history.md) item 2), where the runner's `read-record` reads it. The match is the runner's (`matchDispositions`, `src/core/ship/coordinator.ts`): when the machine reads the record and when the spawn route composes the re-review turn, the dispositions naming the round's finding ids are kept and one naming an id the review never issued is dropped, with a note to the re-review (`Dispositions naming no finding of the previous round (dropped): …`), so the state, the cap report and the reviewer agree on what was answered. A context without the sink (outside the run loop: a unit test's, a CLI's) is told that no run is recording, never a false "recorded" ack. 7. **The findings step: the review's findings are a message into the unit thread, and the coding session there addresses everything and repushes** ([record 0034](../../decisions/0034-one-agent-per-unit-a-run-continues-a-transcript.md)). After a verdict that requests changes the runner enters `findings` (item 15), never a `fix` child briefed from the review: the bot dispatches the review's findings into the unit thread as the requester with the coordinator's tag through the spawn route (a `findings` brief naming the review run, [http-ingress.md](http-ingress.md) item 9), the directive `agent:coding` explicit in the message's text so a person's `agent:review` detour in the thread or a lost store never routes the findings elsewhere. The message is what the requester would paste: every finding verbatim with its id, the review's own words, and the ask (a disposition per finding through `submit_dispositions`, the description resubmitted, the branch pushed, never a merge or an approve; the `address-review-findings` skill carries the craft); it carries no contract and no finding-id tag. The run resolves `coding` by directive and starts as any coding run in that thread does, so on a harness that keeps the thread's coding session the agent that wrote the code answers its review with the reasons it had, and on a native coding preset the run seeds from the channel with the findings as the request. A run live in the unit thread at that moment is a person's (the runner awaited its own child's end): the spawn is refused `coordinator_thread_live` ([thread-admission.md](thread-admission.md) item 8) and answered `busy`, the step waits and asks again under the unit's wall clock, and a busy answer that reaches the reserve ends the unit `wall_clock_cap` with no coding run started (item 8). Every severity including nits gets a disposition, commits are squashed coherent, the description is resubmitted (the bot re-renders and edits the PR at the new head), and the branch repushed. A coding round — round 0 or a findings step — that pushed the pipeline branch onto an open PR without resubmitting gets the same bounded description turn a plain coding run gets ([pr-description.md](pr-description.md) item 5), while its workspace is still attached, before its post-step; only a turn that still submits nothing leaves the post-step's warning. The post-step's note reaches the unit's thread like any coding run's, and a findings step that opened a NEW PR (the old one closed out from under the pipeline) is adopted at the round's `pr-check` for every later round and report. A findings step that ends with the branch still at the previously reviewed head repushed nothing — the unit aborts with that reason instead of burning a review round on the same diff, with ONE exception: a round that declined EVERY finding on the record changes no code on purpose, and the re-review still runs over the same head to verify those arguments and possibly concede (the decline path's designed resolution — `maxRounds` still bounds a decline stalemate). **The runner passes the decision's tier to its children** (the one-door plan's tiers rule; [routing-and-config.md](routing-and-config.md) item 2): the spawn route's body takes `model` (`/`) and `effort`, each refused by name when malformed (`parseSpawnStep`) and the model held to the child preset's allowed tiers before any store is read (`spawnTierRefusal` — a coding or review child never runs the fast tier, `spawn_tier`), and writes them into the child's request as its own `model:`/`effort:` directives (`childRequestText`), the request slot of the resolve ladder, so the child runs on the tier the decision named ahead of every scope. Rounds are strictly serial — the runner waits on each child's `run-finished` event and confirms its record before the next spawn (item 15) — and an operator stop of a child ends the unit as a stop naming the mode; the stop is honored between outcomes, never over one: a review verdict that already posted settles first — an approve that posted is merge-ready, stop or no stop, and a stopped report names a posted changes-requested review. -8. **Caps are ceilings, honestly reported.** The pipeline's wall clock is the ship run's effective profile ([routing-and-config.md](routing-and-config.md) items 2 and 4): the ship preset's declared budget — `ship.maxMinutes`, one number the profile and `resolveShipCaps` both read, the registry's 240 by default — as a boundary on the path or the request's `budget:` directive clipped it (`caps.maxMinutes` is `profile.minutes`; `maxRounds` stays the config block's), handed to the runner on the instance record as each unit's clock. The card names the clip before the fork like any run's (`budget 200 min (channel boundary; preset asks 240)`), and the ship run's ledger row and record carry the profile under the preset's name. The child rounds run under that profile: each attaches on its own preset's class and identity — within the parent's, which the gate judged once before the fork, because `coding` and `review` declare ship's class and an identity at or under ship's `write` — and is spawned with the minutes `carve` hands it — the unit's remainder minus the reserve for the rounds after it, capped at its ask — and a round whose carve falls under its floor is not dispatched (the `Budgets` bullet above). **The fit is asserted before the fork as well as at config load**: `fit` (`src/core/budgets.ts`) says whether a pipeline's minutes hold its first child at its ask and every later round at its floor — `provision + ask(coding) + reserve(coding)`, 163 at three review rounds and 189 at four — and the fork refuses a ship request whose effective minutes (a boundary or a `budget:` directive clipped) fall under that sum, naming the sum on the card and in the reply, opening no instance; `validateShip` refuses a deployment's `ship.maxMinutes`/`ship.maxRounds` pair the same way at load, so a pipeline that cannot hold its own loop never caps out on every unit. A plan runner's child card names the carve (`budget 45 min (carved by the plan runner from the pipeline's remaining clock; preset asks 90)`). Worst-case rounds exceed the 240-min default by design (three full worst-case rounds ≈ 420 min): the wall clock, not `maxRounds`, ends most worst-case pipelines, while typical rounds run far below their ceilings. A cap report distinguishes **declined** findings (disposition recorded) from **unaddressed** ones (no disposition), computed against the LAST review round's findings with only the dispositions recorded for them — a carried-forward finding keeps its disposition, a reused id inherits nothing. **A unit idles instead of ending when the flag is on** ([record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md)): with the resolved `ship.idleDays` above zero, every ending but `merged`, `closed`, `already_landed`, `merge_ready`, `refused` and a draft `held` becomes `idle`; a human-gated `held` becomes `idle` regardless of this flag because the finding is a pending question (`idleEnding` in `src/core/ship/coordinator.ts`, at the machine's one ending funnel; a draft hold waits only for the pull request to become ready, while a human-gated hold is always the pending question and a blocked hold — issue 2086 — idles when configured: in both question cases the person's word is exactly what the wake carries) — the old kind as `why`, the old ending carried whole so its report renders byte for byte at every level (the thread's copy at the request's verbosity, routing-and-config item 28), and what a continuation needs on the ending (the renewals the grant still holds, unspent; the head to continue from — a `review_pending`'s own pending head, else the last coding child's, and the driver still names it as `headSha` so the row's `lastPush` survives the idle; the last coding child's run id, absent when none ran; the spend; its handoff) — the round notes keeping the old kind's outcome; `unit-end` writes it on the unit's row as `idle` with `wakes: 0` and no `ending` ([run-history.md](run-history.md) item 50), leaves the unconsumed thread events waiting, and the unit's facts, its page and the parent card read `idle · `. The driver parks the walk behind the idle under `/idle/` for the days left, wakes through the durable `unit-wake` answer keyed by that identity, and either opens the answered segment, waits again, settles a stopped unit or ends `idle_expired`; later units cannot start while it waits. The wake re-resolves the requester's scoped grant, sets progress aside but still enforces its count, cost cap and fit, consumes each attributed event once, and stores the answer, marks and segment row atomically; a stopped segment reopens under its remaining lease only when that remainder reaches `leaseMinimum("coding")`. The flag still ships at 0 — at zero every ending and continuation sentence is byte for byte today's; above zero the stop, renewal and interruption cards say “reply in this thread to continue”, and the segment card names the folded senders. +8. **Caps are ceilings, honestly reported.** The pipeline's wall clock is the ship run's effective profile ([routing-and-config.md](routing-and-config.md) items 2 and 4): the ship preset's declared budget — `ship.maxMinutes`, one number the profile and `resolveShipCaps` both read, the registry's 240 by default — as a boundary on the path or the request's `budget:` directive clipped it (`caps.maxMinutes` is `profile.minutes`; `maxRounds` stays the config block's), handed to the runner on the instance record as each unit's clock. The card names the clip before the fork like any run's (`budget 200 min (channel boundary; preset asks 240)`), and the ship run's ledger row and record carry the profile under the preset's name. The child rounds run under that profile: each attaches on its own preset's class and identity — within the parent's, which the gate judged once before the fork, because `coding` and `review` declare ship's class and an identity at or under ship's `write` — and is spawned with the minutes `carve` hands it — the unit's remainder minus the reserve for the rounds after it, capped at its ask — and a round whose carve falls under its floor is not dispatched (the `Budgets` bullet above). **The fit is asserted before the fork as well as at config load**: `fit` (`src/core/budgets.ts`) says whether a pipeline's minutes hold its first child at its ask and every later round at its floor — `provision + ask(coding) + reserve(coding)`, 163 at three review rounds and 189 at four — and the fork refuses a ship request whose effective minutes (a boundary or a `budget:` directive clipped) fall under that sum, naming the sum on the card and in the reply, opening no instance; `validateShip` refuses a deployment's `ship.maxMinutes`/`ship.maxRounds` pair the same way at load, so a pipeline that cannot hold its own loop never caps out on every unit. A plan runner's child card names the carve (`budget 45 min (carved by the plan runner from the pipeline's remaining clock; preset asks 90)`). Worst-case rounds exceed the 240-min default by design (three full worst-case rounds ≈ 420 min): the wall clock, not `maxRounds`, ends most worst-case pipelines, while typical rounds run far below their ceilings. **Push-before-abort is keyed to independent end facts:** the run record carries the final Git head the run loop observed as `headSha`, beside the handoff and pushed heads; `read-record` returns all three. A completed child with a handoff whose `by: "salvage"` push is exactly that head takes the pull-request recovery path and enters review, because salvage only republished finished work; a different head, another terminal status or a missing handoff remains an interrupted checkpoint. A cap report distinguishes **declined** findings (disposition recorded) from **unaddressed** ones (no disposition), computed against the LAST review round's findings with only the dispositions recorded for them — a carried-forward finding keeps its disposition, a reused id inherits nothing. **A unit idles instead of ending when the flag is on** ([record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md)): with the resolved `ship.idleDays` above zero, every ending but `merged`, `closed`, `already_landed`, `merge_ready`, `refused` and a draft `held` becomes `idle`; a human-gated `held` becomes `idle` regardless of this flag because the finding is a pending question (`idleEnding` in `src/core/ship/coordinator.ts`, at the machine's one ending funnel; a draft hold waits only for the pull request to become ready, while a human-gated hold is always the pending question and a blocked hold — issue 2086 — idles when configured: in both question cases the person's word is exactly what the wake carries) — the old kind as `why`, the old ending carried whole so its report renders byte for byte at every level (the thread's copy at the request's verbosity, routing-and-config item 28), and what a continuation needs on the ending (the renewals the grant still holds, unspent; the head to continue from — a `review_pending`'s own pending head, else the last coding child's, and the driver still names it as `headSha` so the row's `lastPush` survives the idle; the last coding child's run id, absent when none ran; the spend; its handoff) — the round notes keeping the old kind's outcome; `unit-end` writes it on the unit's row as `idle` with `wakes: 0` and no `ending` ([run-history.md](run-history.md) item 50), leaves the unconsumed thread events waiting, and the unit's facts, its page and the parent card read `idle · `. The driver parks the walk behind the idle under `/idle/` for the days left, wakes through the durable `unit-wake` answer keyed by that identity, and either opens the answered segment, waits again, settles a stopped unit or ends `idle_expired`; later units cannot start while it waits. The wake re-resolves the requester's scoped grant, sets progress aside but still enforces its count, cost cap and fit, consumes each attributed event once, and stores the answer, marks and segment row atomically; a stopped segment reopens under its remaining lease only when that remainder reaches `leaseMinimum("coding")`. The flag still ships at 0 — at zero every ending and continuation sentence is byte for byte today's; above zero the stop, renewal and interruption cards say “reply in this thread to continue”, and the segment card names the folded senders. 9. **LGTM → merge-ready; the merge is the runner's on a plan branch only.** The pull request is part of every transition, never a snapshot the runner owns: before dispatching review or findings, before reading checks and before merging, the runner re-reads its state, current head and same-repository head-ref existence; an unknown branch-existence read is retryable and permits no transition. A merge by another actor is the normal terminal under `merge: person`: `merged` ends the unit with GitHub's reported login and merge sha; closed-unmerged ends it `closed` with the closer; a deleted branch receives no child; a moved head restarts review at that head. A child already live when one of those changes lands is steered through its inbox to end without a push or review post (never stopped), then drained; after a moved-head child drains the adopted pull request is read again before review restarts, so a merge or close during the drain ends the unit; its late verdict, dispositions or push remain on its own record and change no unit state. Merge-ready stands on the POSTED approval: the review child's post-step returns a typed outcome the runner reads back (`reviewPosted`), and an approving verdict whose post failed or was refused by the reviewed-head guard ends the unit with an honest report naming the reason — the PR carries no approving review; re-issue ship in the thread with the PR URL to retry (item 10). On a posted `approve` of a generated one-unit plan's branch the unit ends `merge_ready` and reports: PR link, rounds used, declined findings, the checks at the approved head ([record 0055](../../decisions/0055-a-unit-has-one-thread-and-a-round-reads-the-checks-at-its-head.md): the ending's facts read asks `pr-check` for them, and the headline calls the head merge-ready only over green checks — a pending check is named as pending, and over a failed one the report says approved but not merge-ready and names the check, the ending kind unchanged; no fact read leaves the headline as before — and the READY STATE rides the same read, issue 1460's second half: the facts carry the pull request's `mergeable_state` and the head's self-declared fix-up commits (the `fixup!`/`squash!`/`amend!` autosquash subjects, `fixupCommitSubjects`), so a head that conflicts with its base re-enters the runner's rebase step before an ending is published, while an unsquashed fix-up commit is reported with the squash as the remedy, never "merge-ready"; a clean state and an empty fix-up list leave the headline to the checks), and the pending human merge as the remaining gate — or, when the pull request carries auto-merge at the approved head, that fact instead — or, when it has already merged by the time the ending is composed (auto-merge fired, or a person merged), that merge by commit and time, never a gate that has passed. **The round verdict is now the whole of record 0055's design, not the headline alone:** after a posted approve settles (and past the severity gate), one machine step named `checks` reads the check runs at the reviewed head with the merge door's own reading (total, pending, failed — the bot's `checks` route, classified by `src/core/ship/checkFindings.ts`). A failed check becomes a **check finding** of the round — id `check:`, the check's name as the finding's file, severity `blocking` so the level in force always counts it, the conclusion and URL as its title — under a round note of its own (`checks_failed`, never the parser-mismatch gate), and the findings step then runs as for any changes-requested round: dispositions, a fix round, a re-review — the failed check is the round's answer as soon as it is read, whatever else is still pending, so no `merge_ready` ending and no `merge` step is reached at a head where the checks read failed. **The round cap bounds fix rounds, never the terminal steps** (issue 2023): an approval that lands in the last round `maxRounds` allows proceeds to the checks step and then to the merge (`merge: runner`) or `merge_ready` (`merge: person`) exactly as an approval in an earlier round does; only a findings verdict at the cap ends the unit, and then as `round_cap` with the findings in the report — a red check read at that last approve's head ends `round_cap` too (no fix round remains), the report naming the red check instead of claiming no approval landed — never an unended unit. A check finding sits on no run's record, so it rides the findings brief and the re-review brief by value (`checks` on both brief kinds) and the coding session's dispositions match it by id exactly as a reviewer's; the rows are told apart by provenance — the `check` flag only the machine's `checkFinding` sets — never by the id's `check:` prefix, since a reviewer's id is a free string and a re-review brief names the prior check findings by id, so a reviewer's own `check:…` row rides the review run's record like any finding. **The checks step is one decision table over the reviewed head's facts** (issues 2063 and 1991), read once GitHub has recomputed and re-read on every checks-settled event from the merge-wait book: **(a)** any failed check at the head is the round's answer at once, whatever else is still pending — the check finding and the findings step above, the flake rule keeping its place; **(b)** no failure and a check pending, queued, or expected but not yet reported — a required check of the base's rules (`githubPulls.requiredCheckContexts`, carried as `RoundChecks.expected`: the base's required contexts not among the reported runs), the repository's approve workflow whose run does not exist at the verdict instant being exactly such a check — registers the instance at the head in the merge-wait book and waits on `checks-settled-` in the merge wait's own chunks for at most its sixty-minute ask, then reads again — an unreadable GitHub reads as pending — and a head still pending at the ask's end proceeds, the ending's facts read naming what is pending; **(c)** no failure and every expected check reported green adds no wait: merge-ready, or the runner's merge under `plan:merge`; **(d)** a draft pull request (the `checks` route answers the pull request's own `draft` fact beside the runs) waits for the ready event the same way — marking it ready starts the head's suites, whose completion fires the settled event — and one still a draft at the ask's end ends `held: draft — mark it ready to continue`; a red check on a draft still buys its fix round, cell (a) outranking (d). A head with no required check reported waits one chunk of grace; if the base names required contexts and all remain absent, the runner re-fires `pull_request` once by closing and reopening the pull request without moving its head — a failed reopen is retried, and after GitHub accepts the close the durable effect never completes until the pull request is open again — records `checks restarted` on the round's card, then returns to the normal bounded wait; a repository with no required contexts still proceeds after that grace, and a reviewed head the machine never learned skips the step as the merge step's guard does. And `unfinished` is not an ending a person can read — it is the machine's word for "no ending was chosen": every exit of the table names its cause in the user's nouns in one line, and a unit row a seal finds with a thread and no ending prints "no ending was recorded — re-issue `agent:ship` in its thread to continue", never the bare word (issue 2063). That seal line is a last resort, not a normal exit (issue 2100): any unit step that throws inside the walk — including a stopped or blocked ending — becomes the unit's ending before the Workflow fails — the driver posts `unit-end` with kind `failed`, cause `step_threw`, and a report naming the step, the round and the throw's one line in the user's words ("The runner failed after round N's review verdict: …"), so a walk that dies between a review's verdict and its outcome write leaves a cause a person can read; and the seal's remedy holds either way — a re-issue in the thread of a pull request already approved with green checks resumes at the checks step (item 10's entry facts), never at a fresh coding round. The flake rule: a failure the bot classifies a suspected flake — a test timeout or runner stall whose output names only test files the pull request's changed paths never touch, judged conservatively so anything unprovable is a real failure — is re-run once before it becomes a finding (`githubPulls.rerunFailedJobs`): the Actions `rerun-failed-jobs` retry where an Actions run backs the check, and GitHub's check-run rerequest — the ask to the app that created the run — for any other, this repository's Depot CI legs included; a second failure is the finding, a real failure beside a suspect spends no re-run, and a re-run the bot answers it could not dispatch (`retried: false`) spends the one retry with the head read again at once — never a wait on a settle that never comes. **The transient re-run** (issue 1932): a round-0 coding child whose record names a provider transient (`failure: provider_transient`, riding `read-record` — a model-gateway 5xx, a cut stream, a gateway timeout past the harness's own retry ladder, [agent-coding.md](agent-coding.md) item 11) with nothing pushed — the recover pr-check read `no_commits`, so the ledger row and the branch are untouched and a re-run costs only minutes — re-runs round 0 once instead of aborting the unit: a fresh attempt under fresh step names (`RoundRef.attempt`, the `/a2` suffix on the round's steps) so the Workflow's durable cache never hands the re-run the dead attempt's answers, the boundary reported as the round outcome `transient`; a second transient in the same round is the ending, kind `transient` — named so it reads as a condition in the plane's table beside `checks_failed` and `held`, never as the child failing on its task — while a transient with an ordinary agent push takes the recover path as any dead child's, while a mechanical `by: "salvage"` WIP push aborts with the branch/head named (unfinished work is never sent to review) and `no_base`, a plain none, or a dead child whose record names no transient keeps the abort. When the instance's `merge` field says `runner` (the hand-off writes it: `runner` for a seeded plan, `person` for a generated one — item 16) the approve is followed by the runner's `merge` step under the `plan:merge` grant ([http-ingress.md](http-ingress.md) item 9; [authorization.md](authorization.md) item 2): a squash at exactly the approved head, only with the bot's LGTM standing at that sha and every check green, refused by reason otherwise; a pull request the door finds already merged (auto-merge fired, or a person merged after the approval) ends the unit `merged` with `by: other`, the merge commit and the time — exactly as the pre-check does for one merged before the attempt — never `merge_refused`. **A base that takes changes only through a merge queue is enqueued, never squashed and never refused** (issue 2011): before the squash the door reads the base branch's rules (the `merge_queue` rule of the repository rulesets API, `githubPulls.branchHasMergeQueue`) — or, when the rules could not be read, recognises GitHub's 405 with the queue's wording (`MERGE_QUEUE_405`) after the attempt — and either way enqueues the pull request (the GraphQL `enqueuePullRequest` mutation, the same act `gh pr merge --auto` performs; a pull request already in the queue is success, so a replayed step enqueues nothing twice) and answers `enqueued`; the machine records the boundary once as an `enqueued` round note and stays in its merge wait with every later ask marked `queued`, on which the door reads the queue's outcome instead of squashing — merged from the facts (`by: other`, so the unit ends `merged` as today), still `enqueued` with the position, or `removed` with the queue's own reason (the pull request's `mergeQueueEntry` and the timeline's last `RemovedFromMergeQueueEvent`, `githubPulls.fetchMergeQueueState`); a removal becomes the round's finding — id `check:merge-queue`, severity `blocking`, the reason as the title — under a `dequeued` round note, and a fix round follows exactly as a red check does through the checks step (the round cap standing when the rounds are spent; a removal with no review run to brief the fix from — a resume straight at the merge decision — ends `merge_refused` with the queue's reason); one still queued past the merge wait's budget ends `merge_refused` naming that the queue merges it on its own, and a repository without a queue keeps the direct squash; and the merge:person path names "queued" in its line too — the ending's facts pr-check reads the base's merge-queue rule beside the checks (`baseHasMergeQueue`, read only on `checks: true`, unreadable rules leaving the field out), so the `merge_ready` report's remaining-gate line says the person's merge enqueues the pull request (`gh pr merge --auto`) and the queue merges it on its own, never a direct merge; without the fact, or with no queue, the line is unchanged. The release pull request is never merged by the runner, an instance whose field says `person` or carries none waits for a person naming the field, and, as defense in depth, so does a unit whose branch is not `plan//…`, the refusal naming the field and the branch. Ship's bot-process GitHub writes are exactly the branch create, the PR open/edit, the checks step's one close/reopen recovery, the pinned review post, the board comments, and that one guarded squash; the coding prompts + fix skill carry never-merge/never-approve. Auto-merge is the pull request's OWN fact (`auto_merge` → `PullRequestFacts.autoMergeEnabled`), never the repository's: it is named at entry (the hand-off's reply) and again at the approved head (the driver reads the facts fresh when it composes the `merge_ready` ending — "auto-merge is on for this pull request: the approval merges it once checks pass"), and never refused; no repository-level auto-merge check remains, and a repository lookup failure only leaves the default branch unknown. A conflicting approved pull request (`mergeable_state: dirty`) stays owned by the live runner: the merge door returns the typed conflict before checks are read, and a generated plan's approved-not-merge-ready tail makes the same transition instead of publishing an ending. The runner re-enters rung one of the rebase resolver; an unchanged patch carries the existing approval and returns directly to checks, a changed clean patch returns to review, and a conflict git leaves buys one coding rebase round. Every later base move takes the same path again: fix-round eligibility is per conflict and each round is carved from the run's remaining lease, never spent once for the pull request's lifetime. No pipeline-owned path says “rebase it by hand.” The sweep remains the remedy only when no live runner owns the pull request (item 20). And the coding child's contract instructs a re-fetch of the base right before the push, rebasing once more when the base moved during verify, so the pull request is not born conflicting. **An approve is held to the severity to address** (`review.addressSeverity`, default `minor`; per-channel/user `config set … --review.addressSeverity `, per-run `severity:` — one lever with the review's own gate, [agent-review.md](agent-review.md) item 5a — resolved once by the hand-off, directive > user > channel > org, and written on the instance beside `merge` so the machine reads one value; each review child receives it as the `severity:` directive of its request, item 5, so the child's verdict parser holds the approve to the same level and posts `Changes requested:` over a gated finding): a posted approve carrying a finding at or above the level (every parsed finding carries one of the ladder's four levels — the verdict parser drops an entry with any other severity, `fyi` or none, with a note, so nothing outside the ladder reaches the gate) continues into the findings step exactly as a `request_changes` does — dispositions, a fix round, a re-review — while an approve whose findings all sit below the level ends `merge_ready` (or merges), the report naming the level in force, its source (org, channel, user, run) and the findings it left below the gate (in the full copy — the row's and the board's; the thread's quiet copy is the one [outcome](../vocabulary.md#outcome) line of item 12a and the findings, the level and its source ride it only at `verbose` — [routing-and-config.md](routing-and-config.md) item 28, [record 0066](../../decisions/0066-a-user-meets-twelve-nouns-and-no-others-the-vocabulary-is-a-reference-page-bound-to-the-code-and-the-consistency-check-fails-a-user-surface-that-prints-an-internal-word.md)). With the parser holding the child's verdict to the same level, this check is defense in depth: it bites only on a record whose verdict was parsed at another level — a lost `severity:` directive on the child's brief, a record from before the parser's gate, a harness around `submit_verdict` — and **when it bites it says so**: the round note the machine emits for that approve carries `gate: { level, findings: ["F1 (minor)", …] }`, the driver forwards it on the `round` route, the route holds it to its shape (a level on the ladder, string findings; malformed → 400, nothing appended), keeps it on the unit row's round (`rounds[].gate`, the `ship_round` event's `gate`), draws `⚠️ gate fired: … at or above ` on the card's unit line and warns in the bot's log — so the backstop doubles as a detector for the mismatch instead of routing around it silently. An approve whose findings all sit below the level carries no gate. **A round whose actionable findings are all human-gated parks as a live question, never ending held or opening an unanswered fix round**: a reviewer marks a finding whose remedy is a receipt only a person can produce — a replay needing a provider credential no sandbox holds, a procedure a person runs live — with `humanGated: true` beside its severity ([agent-review.md](agent-review.md) item 5), and the coordinator reads that flag, never prose. When every finding the round would act on carries it — the gated set on an approve, every finding on a `request_changes` — no coding child is spawned before a person answers (a fix round could change nothing yet) and the unit parks as `idle` before the round cap is even asked: the report and the pull request's own comment (the bot's `unit-end` route posts it beside the board's copy) name the human-gated rows and the two answer surfaces — the unit thread and the pull request. One actionable finding beside a human-gated one keeps the fix round: the dispositions cover the human-gated row like any other. A check finding is never human-gated (only the reviewer's tool sets the flag). A human-gated-only round never ends the pipeline held: it parks as the unit's `idle` pending state and later units remain blocked behind that live owner. The next human unit-thread reply or pull-request conversation comment wakes it without spending a renewal; the findings and attributed answer are the coding fix round's brief, followed by re-review. An adopted attempt whose newest human conversation comment postdates the last bot verdict marker takes the same fix-then-review path instead of reviewing first. 10. **Entry checks make restarts safe.** Ship never opens a duplicate PR (open-or-edit by head branch; a re-issued plan finds its unit's pull request at the pre-check, item 15 — and resumes it, never re-codes it: the unit-start's pre-check reads the entry facts beside the listing (`entry: true` — the branch's own tip, whether the bot's approval stands at the head, and the checks there), and an open pull request at the branch's own head enters the review round with that pull request, one approved with green checks resumes straight at the merge decision (`merge_ready` for a person, the merge door under `merge: runner` at exactly that head), and a coding child is spawned only when no open pull request heads the branch or the pull request's head is behind the branch's tip; issue 1689). A ship invocation **resumes at review** — skipping round 0 — when a user turn names the PR (the thread→PR inference reads user turns only), it is open with a same-repo head, and the invocation carries no new task text — for ANY author: the bot-authorship check is gone, and with it the process-identity resolution the entry once required. A generated task in the thread of an open pull request **adopts** it: the generated plan's one unit runs on the pull request's own head branch against its own base — round 0 pushes to that head and the pre-check finds the pull request, no new branch — for a person's pull request exactly as for ship's own. A seeded request (`plan .md`) keeps the plan graph's own `plan//u` branches: a pull request in its thread is context, never adopted, and its facts are never read — a thread pull request that could not be fetched refuses nothing on the seeded path. The fork check runs on adopt and resume: a head that lives on a fork is refused. A PR number quoted as evidence inside new task text in the CURRENT message (`repoContext.prFromMessage`) does not bind — it stays in the task as context and round 0 starts from the repo's default branch (or a typed ref token), never from the quoted PR's head branch, even when the cited PR's facts cannot be fetched or its head is a fork; the round-0 base drops any PR-derived ref (`repoContext.refFromPr`, flagged at the resolver — and set only from an OPEN pull request: a merged or closed PR cited as a receipt contributes no ref hint at all, and a coordinator child's attach ref is its contract's branch, always, any PR-derived ref in the child's own request text dropped before the attach; issue 1860, [resident-repos.md](resident-repos.md) items 16 and 29) and any unit branch of ship's own (`plan//`, `isUnitBranch`): a thread keeps the binding its last run opened a pull request on, so after a plan's unit it sits at that unit branch until the unit's merge deletes the branch — its next attach then returns the binding to the default branch, the branch being one the thread's own run pushed ([resident-repos.md](resident-repos.md) item 16's second movement) — and a task re-issued there would otherwise base the next generated plan on the earlier unit — its pull request targeting that branch instead of the repository's so a failed fetch cannot leak the stranger's head branch. The exception is the thread's OWN pull request: an in-message reference that resolves to the pull request the thread's own run opened or edited (`repoContext.prIsThreadOwn`, set off the thread's record PR wherever the reference came from, beside `prFromMessage`) is the target, never context — it adopts with new task text and resumes without, through the same fork and fail-closed checks as any adopt or resume, and only a CLOSED own pull request beside task text falls through to a fresh entry off the default branch. The unit end reads the child's record the same way before declaring no pull request: a pr-check that finds nothing heading the unit's branch while the machine holds the round's pull request (`pr_opened` off the child's record, or an earlier round's adoption — the child pushed to that pull request's own head branch, not the unit's) runs the review round on that pull request at the head the child pushed — a push onto a branch heading an open pull request is "PR updated", never "no pull request opened" — at round 0 and after every findings step alike, since the same child keeps repushing that branch through every later round (a findings step that repushed nothing keeps item 7's abort). And the fact is verified, never trusted from a record written minutes earlier: the round's pr-check carries the adopted pull request's number, and when nothing heads the unit's branch the bot follows it and answers its LIVE state — open at a fresh head, merged (the unit ends `merged` by other), or closed unmerged (the unit ends terminal `closed` with the closer and briefs no review); an unreadable follow fails closed and the step retries before the machine acts. Kept fail-closed on a fetch failure: an INHERITED thread PR (not in-message), and a BARE in-message reference with no task text (a resume attempt must verify the PR first); a bare reference to a CLOSED pull request is refused — there is nothing to resume. The repository lookup is advisory: a failure leaves the default branch unknown and refuses nothing — an adopt or resume still carries the pull request's own base, and a fresh unit with no base lands on the hand-off's "no base branch is known" refusal. A resume is handed to the runner like every request (item 16): the generated plan's one `U1` row carries `resume` — the pull request, its head and url — and the machine re-reads that pull request at the unit pre-check, then opens it at its first review round with no branch and no round 0 (`openUnitPipeline`'s `resume`), on the pull request's own head branch and base; the review child pins the head and the approve ends `merge_ready` for a person, as item 9 has it. No pipeline state lives in the bot process — the runner's is the Workflow's, durable across bot deaths (item 15) — so a bot death under a pipeline interrupts a **child**: the next generation closes that run `interrupted` and tells its thread ([run-history item 36](run-history.md)), the runner hears the close at once — the `interrupted` record's commit sends the same `run-finished-` event as a finish ([run-history item 47](run-history.md)), so the wait settles on the event, the confirming `read-record` reads `interrupted`, and after the recover pr-check of item 15 found nothing pushed the runner ends the unit with the same note (`shipInterruptedNote`): the PR it had opened, if any, and the exact re-issue that continues the loop — `agent:ship` with only the PR URL (this item's resume-at-review), or with the task when no PR existed (round 0 again on the same deterministic branch as the next attempt; a plan, seeded or generated, is re-issued as its next attempt — and a unit already merged is refused as merged already, item 16). Nothing restarts a pipeline unattended (the ship-restart plan's D1, which the runner keeps). 11. **Resident-only in practice; the children are ordinary runs.** The coding and review presets are `repo-resident` ([execution.md](execution.md) item 18), so a target repository that is not onboarded is refused at each child's authorize stage (`repo_not_onboarded`) — the spawn answers `refused` and the unit ends with the gate's name, never a per-round cold clone the requester did not ask for; a resident that is onboarded but cannot attach falls back exactly as a plain coding or review run does ([execution.md](execution.md) item 8), with the fallback named on the child's card. Each round attaches its own executor with the child agent's toolset (readonly review attach / writable coding attach — the mode-switch wipe is the accepted cost, bounded by clipped budgets). @@ -55,7 +55,7 @@ The coding → review → fix loop to LGTM as one [pipeline](../vocabulary.md#pi | 8: the fit at the fork — a boundary that clips ship under its loop's sum refuses with the sum on the card and in the reply, no instance opens; a boundary at the sum starts the runner | `[unit]` `src/core/dispatch/ship.test.ts::runShipBranch — the agent:ship fork hands every admitted request to the plan runner::a boundary that clips ship under its loop refuses at the fork with the sum…`; `::a boundary at the fit's sum starts the runner…` | | 8: a plan runner's child card names the carve — `carved by the plan runner from the pipeline's remaining clock` — while a person's directive still reads `budget directive` | `[unit]` `src/core/dispatch/provision.test.ts::budgetClipLabel — the card's budget line::names the plan runner's carve for a coordinator's child…` | | 8: the fit — a pipeline holds its first child at its ask and every later round at its floor, `provision + ask(coding) + reserve(coding)`: ship at three review rounds needs 163 of its 240, a fourth round needs 189 — it fits the 240 and is refused at 180 — and the 40-minute deployment that lost a day's children fails it naming 163 | `[unit]` `src/core/budgets.check.test.ts::the fit — a pipeline holds its first child at its ask and every later round at its floor::*` | -| 8: push-before-abort — every ship coding child ending checkpoints a dirty tree or unpushed commit before workspace release, including a completed contract abort or handoff; budget, failed, stopped and interrupted/restarting endings retain their mechanical WIP marker. Each successful checkpoint records `pushed_head by:salvage`; the runner aborts rather than reviews unfinished WIP, names the branch/head a re-issue resumes from, and no checkpointed commit emits the old discarded-work card | `[unit]` `src/core/codingPrPostStep.test.ts::salvageBudgetPush — a ship coding child pushes what it has at the budget (push-before-abort, agent-ship.md item 8)::*`; `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a coding child's final description turn checkpoints dirty work before release`, `::a coding child that ends with an unpushed commit checkpoints it before release and its card never says discarded`, `::a coding child whose transport retry budget is exhausted commits and pushes its interrupted workspace before teardown`, `::a stopped coding child checkpoints its WIP before the hard-stop teardown`, `::a restarting coding child pushes a WIP checkpoint before release and its card never says the work was discarded`; `src/core/ship/coordinator.test.ts::the transient re-run — round 0 dies on a provider transient with nothing pushed (issue 1932)::a completed coding child whose ending checkpoint reached the unit branch aborts with the resumable head`, `::a transport failure whose WIP checkpoint reached the unit branch aborts with the recoverable head instead of reviewing unfinished work`; `src/core/ship/coordinator.test.ts::the unit pipeline — the event, the timeout and the confirmation (the durable half)::an interrupted coding child whose WIP checkpoint reached the unit branch ends with that recoverable head, never a discarded-work restart` | +| 8: push-before-abort — every ship coding child ending checkpoints a dirty tree or unpushed commit before workspace release, including a completed contract abort or handoff; budget, failed, stopped and interrupted/restarting endings retain their mechanical WIP marker. Each successful checkpoint records `pushed_head by:salvage`; the runner aborts rather than reviews unfinished WIP, names the branch/head a re-issue resumes from, and no checkpointed commit emits the old discarded-work card. The run's independently observed final `headSha` is persisted and returned by production `read-record` beside `handoff` and `pushed`: completed + handoff + same-head salvage opens or recovers the pull request and enters review; a differing returned head, a missing handoff or any other ending keeps the checkpoint. | `[unit]` `src/core/codingPrPostStep.test.ts::salvageBudgetPush — a ship coding child pushes what it has at the budget (push-before-abort, agent-ship.md item 8)::*`; `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a coding child's final description turn checkpoints dirty work before release`, `::a coding child that ends with an unpushed commit checkpoints it before release and its card never says discarded`, `::a coding child whose transport retry budget is exhausted commits and pushes its interrupted workspace before teardown`, `::a stopped coding child checkpoints its WIP before the hard-stop teardown`, `::a restarting coding child pushes a WIP checkpoint before release and its card never says the work was discarded`; `src/channels/adminCoordinator.test.ts::POST /admin/coordinator/read-record — the renewal's facts off the record (decision 0046)::a completed child's production read-record view carries its observed final head, so same-head salvage opens the pull request and enters review`, `::a completed child whose salvage differs from the head returned by production read-record still aborts at the checkpoint`; `src/core/ship/coordinator.test.ts::the transient re-run — round 0 dies on a provider transient with nothing pushed (issue 1932)::a stopped child keeps its same-head salvage checkpoint even when it submitted a handoff`, `::a completed coding child whose ending checkpoint reached the unit branch aborts with the resumable head`, `::a transport failure whose WIP checkpoint reached the unit branch aborts with the recoverable head instead of reviewing unfinished work`; `src/core/ship/coordinator.test.ts::the unit pipeline — the event, the timeout and the confirmation (the durable half)::an interrupted coding child whose WIP checkpoint reached the unit branch ends with that recoverable head, never a discarded-work restart` | | 8: the grant — `DEFAULT_GRANT` is zero renewals and no cap, `GRANT_RENEWALS_MAX` two days of ship segments; `resolveGrant` layers user > channel > org with a `renewals:` directive setting the count alone; `ship.grant` and a scope's `ship.grant` load and are refused by name (a count outside 0–12, a non-positive cap, an unknown field, a non-mapping); the hand-off writes the grant and its source on the instance, the default when the fork carried none; the plan route answers them; the unit report names spent of granted, the cap and the granter; `renewals:` parses and strips like the other directives, is refused outside the ceiling and is never sticky | `[unit]` `src/core/budgets.check.test.ts::the grant — what the request authorizes beyond one lease, sized by the person::*`; `src/config.test.ts::ship caps block (agent:ship pipeline)::ship.grant: parsed at load on the org block and the scopes…`; `src/core/coordinator/handOff.test.ts::the grant — resolved once, written on the instance beside \`merge\` (decision 0046, the renewable lease)::*`; `src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::plan answers the instance's units with where each stands…`; `src/core/ship/coordinator.test.ts::the severity gate — an approve's findings held to the level in force::the report names the grant the instance carries…`; `src/directives.test.ts::parseDirectives — renewals::*` | | 8: the idle ending — each idling kind maps to `idle` with itself as `why`, its report intact at every verbosity and the continuation facts filled at `idleDays: 7` (an idled `review_pending` continues from its own pending head; an interrupted review child names the last coding child's run); the four ended kinds never map; at 0 and absent nothing maps | `[unit]` `src/core/ship/coordinator.test.ts::the idle ending — an idling kind maps to \`idle\` when ship.idleDays is above zero (record 0051)::*`; `src/core/coordinator/driver.test.ts::the plan runner's driver — a shipped pull request at the wall-clock cap (review pending)::an idled review_pending continues from the pending head…` | | 8: the idle flag — `ship.idleDays` accepts 0 and 365 and is refused by name outside them on the org block and a scope; an idle parks under indexed waits, a timeout or the wake limit ends `idle_expired`, a segment answer continues with its attributed texts, and at zero no idle is emitted | `[unit]` `src/config.test.ts::ship caps block (agent:ship pipeline)::ship.idleDays (record 0051): accepts 0 and 365…`, `src/core/coordinator/driver.test.ts::the plan runner's driver — the Workflow body over the step runner (item 9)::an idle ending waits under the first indexed identity…`, `::a nudge answered without a segment re-enters the indexed wait…`, `::a wake segment carries the handoff…`, `::a wake segment carrying leaseMs reopens the cut segment…`, `::an explicit expired answer…`, `::a wake that throws after the step retries…`, `::later units do not start while the walk is parked…`, `::idleDays zero never emits an idle ending…` | diff --git a/docs/reference/specs/run-history.md b/docs/reference/specs/run-history.md index 151bb74be..d2e827b78 100644 --- a/docs/reference/specs/run-history.md +++ b/docs/reference/specs/run-history.md @@ -11,8 +11,8 @@ Every [run](../vocabulary.md#run) becomes a durable record — identity, timing, ### The record 1. **Node-free contract.** `src/core/runRecord.ts` has no Node built-in imports and no I/O or clock: bytes are measured with `TextEncoder`, and callers pass `nowMs`. The Cloudflare state Worker imports it by relative path, the same way it imports `frictionProposals.ts`, so the bot and the Worker validate, trim and page with ONE implementation. -2. **Record shape.** `RunRecord = { id, label?, agent?, model?, channelId, userId, relayedBy?, authenticatedAs?, threadKey, channelVisibility, repo?, startedAt, finishedAt, receivedAt?, sealedAt?, replyOk?, replyNote?, stepCount?, schema?, status, eventCount, storedEventCount, truncated, events, diagnosis, record?, recordTaskKey?, handoff?, verdict?, reviewHead?, reviewPost?, dispositions?, profile?, parentRunId?, seed?, pr?, pushed?, lease?, route?, provisional?, instanceId?, pipeline?, hosted? }`. `record` is the runner-reserved decision-record number and `recordTaskKey` is the stable key for a direct coding task; a direct re-issue pages through that thread's retained coding records until it finds the key or exhausts history, never stopping at the newest page. (`pipeline` the standing fold's summary over the run's own `ship_round`/`ship_unit` events — `PipelineSummary`, [record 0065](../../decisions/0065-a-hosted-pipeline-run-is-an-orchestrator-every-surface-reads-its-standing-from-its-own-events-never-from-a-model-runs-signals.md), [agent-ship.md](agent-ship.md) item 19 — folded at the one record assembly so the stored row reads the standing without the events; `hosted` the ship parent's marker, `RunMeta.hosted` carried onto the record at the seal, the literal `true` or absent like `provisional`; both absent on every other run and on records written before them, which read as today) (`profile` is item 45, `parentRunId` item 46, `seed` item 52, `lease` the run's lease as its harness started it — `{ startedAt, endsAt, loopEndsAt }` off the first `lease` event, folded at the one record assembly, present only on a run whose harness started a loop ([harness-pi.md](harness-pi.md) item 15, [decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pushed` the heads the run pushed — `{ ref, sha, by? }[]`, one per branch with the last `pushed_head` event's sha and source winning, folded at the one record assembly (`by` is `"push"` for an ordinary coding push or `"salvage"` for a mechanical checkpoint, and is absent only on records from before the source survived the fold); the event is published by the coding post-step when the observation proves a push of a branch that is not the base (`by: "push"`) and by the mechanical checkpoints — every abnormal coding-child ending (including budget end) and the compaction checkpoint ([harness-pi.md](harness-pi.md) item 7), all `by: "salvage"` — whether or not a [pull request](../vocabulary.md#pull-request) follows, so a renewal reads progress off the row without a model ([decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pr` the pull request the coding post-step opened or edited — `{ number, url, head? }` off the last `pr_opened` event, folded at the one record assembly (the event itself may also carry `rewritten` — how many of the run's commits the identity rewrite re-authored before the open, [record 0062](../../decisions/0062-the-requester-authors-the-commits-the-bot-commits-and-opens-them-and-a-run-keeps-the-requester-who-started-it.md), [agent-coding.md](agent-coding.md) item 2; absent when none were), `head` the branch the PR is opened from when the event names it — the fact a [follow-up](../vocabulary.md#follow-up) in the [thread](../vocabulary.md#thread) continues from, [resident-repos.md](resident-repos.md) item 29, and the branch the run's release hands the resident to remember, [resident-repos.md](resident-repos.md) item 16a; `verdict` the [verdict](../vocabulary.md#verdict) a review run submitted through `submit_verdict` — [agent-review.md](agent-review.md) — with its findings, `reviewHead` the head it reviewed and posted against after the settle, 7 to 40 lowercase hex, `reviewPost` how its post-step ended — `{ posted: true, target: { repo, number }, head, verdict? }` or `{ posted: false, reason }`, [agent-review.md](agent-review.md) item 18 — and `dispositions` the set a coding run submitted through `submit_dispositions`, as submitted, the plan runner matching it to its [round](../vocabulary.md#round)'s findings when it reads the record — [agent-ship.md](agent-ship.md) item 6 — each redacted at every string leaf in the one record assembly and present only on a run that produced it, so a coordinator's `read-record` answers a review child's verdict, whether it was posted, and a coding run's dispositions from the record — through `RunsService`, which supplies them from the store the moment it holds the record, a finished row still inside the registry's window included, item 21) (the five optional stamps per [tracing.md](tracing.md): `stepCount` is the registry's content-event count, span records excluded; `schema` the stream schema, 2 once spans are emitted; `handoff` the typed handoff a coding child submitted — [agent-ship.md](agent-ship.md) item 14 — redacted like every stored string and present only on a run that submitted one, an affirmed empty handoff being three empty lists, distinguishable from none) (`instanceId` the plan runner instance a ship run's hand-off created — [record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) R2 — folded at the one record assembly from the last `ship_handoff { instanceId, at }` event the ship branch publishes after a successful hand-off and never after a refusal, projected like the coordinator tag, exposed on `RunView.instanceId`, and how the thread's owner rule finds the instance, [thread-admission.md](thread-admission.md) item 9; the consuming run's `input` event may carry `mode` and `consumed` — the thread events it folded, record 0051's mode-as-receipt rule; a HOSTED ship parent's own record ([record 0060](../../decisions/0060-a-ship-pipeline-is-a-live-run-for-its-whole-life-and-runs-on-every-channel-that-can-open-a-thread.md)) carries TWO `run_meta` events — the dispatch's, then the hand-off's naming the runner instance in `run_meta.instanceId` — plus the `ship_unit` facts the runner's routes published ([agent-ship.md](agent-ship.md) item 17): every reader resolves such a run's instance from the LAST `run_meta` carrying one, and both event kinds pass the validator like any other) with `status ∈ completed | stopped_soft | stopped_hard | failed | interrupted` and `channelVisibility ∈ public | private | dm | machine | unknown` — the channel's visibility as the `ChannelDirectory` reported it at dispatch ([authorization.md](authorization.md) item 7), what `member-of` reads; a record written before the stamp existed is accepted and reads as `unknown` (`normalizeStored`), never public — `interrupted` marks a run cut down before finish (container replaced or crashed; the provisional tombstone and the drain-deadline write of item 27 are its only writers), and `provisional?: true` is those two writers' stamp on the record itself (item 27): absent on every final write, so a store-only reader tells "no finish recorded yet" from a real interrupt that owns no live row. `eventCount` is what the run published; `storedEventCount` is `events.length`; `truncated` says the two differ (the registry backlog or the byte budget dropped events). Every stored event keeps the registry's `seq` (`StoredRunEvent`; `storedEventSeqs` falls back to positions only for a record whose events arrived without strictly increasing stamps). `RunListItem` is the record minus `events` plus an optional `bytes` (the stored JSON size) — `diagnosis` stays on the list item so the friction ledger can be served from a listing without loading events. How the run's preset was chosen rides the stream ([routing-and-config.md](routing-and-config.md) item 21): `run_meta.agentSource` — `directive`, `sticky`, `user`, `channel`, `default` or `route` — on every run since the router landed (absent on older records and on a command run), and a `route { preset, reason, model, parts?, collapsed?, command?, input?, receipt?, outcome?, handBackRunId? }` event right after `run_meta` on a run the router answered (head material, a side fact like `pr_opened`, never a step) — on a command run the router bound from prose ([routing-and-config.md](routing-and-config.md) item 21's command menu) `preset` is `command`, `command` the id the model called, `input` the bound `{ args, options }` with every string redacted and cut at `ROUTE_COMMAND_VALUE_CAP`, `receipt` the chat form the reply led with, redacted and cut at `ROUTE_RECEIPT_CAP`; how the invoke ended is the run's own `status` and `answer`; and, on a door decision about a state change ([record 0044](../../decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md)), `outcome`: `hand_back` on the record of a routed write the door handed back — a command run that invoked nothing, `completed`, its `answer` the line to paste, written by `recordRoutedDecision` with no run signalled to the channel — `offered` on the record of the same decision on a channel that can show a confirmation ([routing-and-config.md](routing-and-config.md) item 25) — a command run that invoked nothing, `completed`, its `answer` the offer as the channel shows it (the full line and the risk line), the row minted in the config object — `confirmed` on the record of the stored input run at the click, as the requester, through the typed line's path (`reason: confirmed after offer`, `source: confirm` on the audit line; its status and `answer` the command's own, as a routed read's are), and `pasted` on the record of the typed line that followed a hand-back in the same thread with the same capped receipt, whatever the command, `handBackRunId` naming the hand-back's record ([routing-and-config.md](routing-and-config.md) item 21). A sixth outcome, `refused`, on the record of a routed decision a gate refused after the bind ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md)): written by `recordRoutedDecision` from the confirm path when the store's refusal still names the row ([routing-and-config.md](routing-and-config.md) item 25: `expired`, `foreign`) — a command run that invoked nothing, `completed`, `reason: refused after offer`, its `answer` the refusal's named line, the event carrying the row's command, input and receipt and `refusalCode` (a code from `src/core/refusal.ts`) — and the door report counts such records per day, cause and code ([load-harness.md](load-harness.md) item 19). Every other refusal is a run record too ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md), as amended): a gate refusal before any command is bound and before admission, a silent refusal and the catch-all's `uncaught` alike leave one record with agent `door` — written by `recordRefusal` beside the rendered sentence, the refusal's code leading the label, status `completed`, no surface told of the run and no thread claimed — whose events are the redacted request as `input` and exactly one `refusal { code, cause, text }`, the sentence redacted and capped at `ROUTE_RECEIPT_CAP`, the cause the one table's for the code; a message without a thread of its own records with the channel as its thread key, a refusal that already recorded through `recordRoutedDecision` records once, and a refused click on a question's stored proposal records the proposal as its request; a `used` click and an unreadable store name no row and leave no record — there is no message to record — and stay countable from the trace's `refusal`/`cause` root attributes ([tracing.md](tracing.md) item 3), which every refusal still carries unchanged. A run a question's Yes redispatched ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md); [routing-and-config.md](routing-and-config.md) item 21) names the question on its record: a `run_note { kind: redispatch, summary: "confirmed after question " }` published before the first turn, `` the refusal code the stored row carried, so the Yes-run is traceable to the question whose proposal it ran. Which command runs are recorded: the inline-run commands (`isInlineRunCommand`, the ones that do work) and every routed decision whose `route` carries an `outcome`; a typed no-work command outside a paste, and a routed read of one, leave no record, as before: `parts` on a compound routed to the conductor — one `{ preset, text }` per child the brief told it to spawn, the text redacted and capped as the child's whole prompt — or `collapsed: { presets }` on the write run a compound answer collapsed onto ([routing-and-config.md](routing-and-config.md) item 21: a write ask is never a part, so the answer became one route to that preset), the preset each part named in answer order and no `parts`, and, for a compound the parse refused, the event on the run that fell to the default, `preset` being `defaults.agent` and `reason` reading `compound_rejected: ` while `agentSource` stays `default` ([routing-and-config.md](routing-and-config.md) item 21); the replay (`load route`, [load-harness.md](load-harness.md) item 17) reads both to tell a requester's own choice from a fallback and to leave the router's own answers out of its labels. The decision itself also rides the record as `route? { preset, reason, model, parts?, collapsed? }` ([routing-and-config.md](routing-and-config.md) item 21): the run's own route — folded at the one record assembly from the dispatcher's decision, or from the `route` event when `run_meta.agentSource` is `route` (`routeOfEvents`) — or, on a sticky-by-transcript follow-up that continued a routed thread, the thread's decision carried forward with no event; on the record (and its `RunListItem` and `RunView`) so the next follow-up's card can keep its route note without reading the events. -3. **Structural validator.** `isRunRecord(x)` accepts only an object whose `id` matches `RUN_ID_PATTERN` (`^[A-Za-z0-9_-]{1,64}$`), whose identity/timing/count fields have the right types, whose `status` is one of the five, whose `events` is an array of objects each with a string `type` (the union grows over time — an older reader must still accept a newer record, and the run-page fields `callId`/`exitCode`/`output`/`source` ride through verbatim), and whose `diagnosis` is structurally a diagnosis (`isStoredDiagnosis`: a `byCategory` map of `{ count, durationMs }` totals — NOT the current category list), and whose `verdict`, `reviewHead` and `dispositions`, when present, have their stored shapes (`isReviewVerdictShape`, the head pattern, `isFindingDispositionsShape` — shape only, no bounds, like the handoff), and whose `handoff`, when present, has the handoff's shape (`isHandoffShape`, [agent-ship.md](agent-ship.md) item 14: three lists of string-field entries — shape only, no bounds, because redaction may lengthen a stored string), and whose `provisional` and `hosted`, when present, are each the literal `true` (item 27's persisted stamp and record 0065's hosted marker; any other value is a malformed record), and whose `pipeline`, when present, has the summary's shape (`isPipelineSummaryShape` — shape only, so an older reader accepts a newer record's field). Readers zero-fill categories a stored diagnosis lacks and drop unknown ones (`normalizeDiagnosis`), so adding a friction category never invalidates a stored record. Anything else — `null`, a bad id, an unknown status, an event without a `type` — is rejected. `isRunListItem` is the same check minus events, plus an optional numeric `bytes`. +2. **Record shape.** `RunRecord = { id, label?, agent?, model?, channelId, userId, relayedBy?, authenticatedAs?, threadKey, channelVisibility, repo?, startedAt, finishedAt, receivedAt?, sealedAt?, replyOk?, replyNote?, stepCount?, schema?, status, eventCount, storedEventCount, truncated, events, diagnosis, record?, recordTaskKey?, headSha?, handoff?, verdict?, reviewHead?, reviewPost?, dispositions?, profile?, parentRunId?, seed?, pr?, pushed?, lease?, route?, provisional?, instanceId?, pipeline?, hosted? }`. `record` is the runner-reserved decision-record number and `recordTaskKey` is the stable key for a direct coding task; a direct re-issue pages through that thread's retained coding records until it finds the key or exhausts history, never stopping at the newest page. (`pipeline` the standing fold's summary over the run's own `ship_round`/`ship_unit` events — `PipelineSummary`, [record 0065](../../decisions/0065-a-hosted-pipeline-run-is-an-orchestrator-every-surface-reads-its-standing-from-its-own-events-never-from-a-model-runs-signals.md), [agent-ship.md](agent-ship.md) item 19 — folded at the one record assembly so the stored row reads the standing without the events; `hosted` the ship parent's marker, `RunMeta.hosted` carried onto the record at the seal, the literal `true` or absent like `provisional`; both absent on every other run and on records written before them, which read as today) (`profile` is item 45, `parentRunId` item 46, `seed` item 52, `lease` the run's lease as its harness started it — `{ startedAt, endsAt, loopEndsAt }` off the first `lease` event, folded at the one record assembly, present only on a run whose harness started a loop ([harness-pi.md](harness-pi.md) item 15, [decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pushed` the heads the run pushed — `{ ref, sha, by? }[]`, one per branch with the last `pushed_head` event's sha and source winning, folded at the one record assembly (`by` is `"push"` for an ordinary coding push or `"salvage"` for a mechanical checkpoint, and is absent only on records from before the source survived the fold); the event is published by the coding post-step when the observation proves a push of a branch that is not the base (`by: "push"`) and by the mechanical checkpoints — every abnormal coding-child ending (including budget end) and the compaction checkpoint ([harness-pi.md](harness-pi.md) item 7), all `by: "salvage"` — whether or not a [pull request](../vocabulary.md#pull-request) follows, so a renewal reads progress off the row without a model ([decision 0046](../../decisions/0046-a-budget-is-a-lease-carved-from-its-parent-and-one-module-proves-the-leases-fit.md)); `pr` the pull request the coding post-step opened or edited — `{ number, url, head? }` off the last `pr_opened` event, folded at the one record assembly (the event itself may also carry `rewritten` — how many of the run's commits the identity rewrite re-authored before the open, [record 0062](../../decisions/0062-the-requester-authors-the-commits-the-bot-commits-and-opens-them-and-a-run-keeps-the-requester-who-started-it.md), [agent-coding.md](agent-coding.md) item 2; absent when none were), `head` the branch the PR is opened from when the event names it — the fact a [follow-up](../vocabulary.md#follow-up) in the [thread](../vocabulary.md#thread) continues from, [resident-repos.md](resident-repos.md) item 29, and the branch the run's release hands the resident to remember, [resident-repos.md](resident-repos.md) item 16a; `verdict` the [verdict](../vocabulary.md#verdict) a review run submitted through `submit_verdict` — [agent-review.md](agent-review.md) — with its findings, `reviewHead` the head it reviewed and posted against after the settle, 7 to 40 lowercase hex, `reviewPost` how its post-step ended — `{ posted: true, target: { repo, number }, head, verdict? }` or `{ posted: false, reason }`, [agent-review.md](agent-review.md) item 18 — and `dispositions` the set a coding run submitted through `submit_dispositions`, as submitted, the plan runner matching it to its [round](../vocabulary.md#round)'s findings when it reads the record — [agent-ship.md](agent-ship.md) item 6 — each redacted at every string leaf in the one record assembly and present only on a run that produced it, so a coordinator's `read-record` answers a review child's verdict, whether it was posted, and a coding run's dispositions from the record — through `RunsService`, which supplies them from the store the moment it holds the record, a finished row still inside the registry's window included, item 21) (the five optional stamps per [tracing.md](tracing.md): `stepCount` is the registry's content-event count, span records excluded; `schema` the stream schema, 2 once spans are emitted; `headSha` the final Git head the run loop independently observed after every tail step and mechanical salvage, 7 to 40 lowercase hex, persisted at the one finish assembly and returned by the coordinator's production `read-record` beside `handoff` and `pushed`; absent when the workspace had no readable Git head and on older records; `handoff` the typed handoff a coding child submitted — [agent-ship.md](agent-ship.md) item 14 — redacted like every stored string and present only on a run that submitted one, an affirmed empty handoff being three empty lists, distinguishable from none) (`instanceId` the plan runner instance a ship run's hand-off created — [record 0051](../../decisions/0051-a-thread-has-one-owner-for-its-life-a-message-is-one-event-in-a-chosen-mode-and-a-pipeline-idles-instead-of-ending.md) R2 — folded at the one record assembly from the last `ship_handoff { instanceId, at }` event the ship branch publishes after a successful hand-off and never after a refusal, projected like the coordinator tag, exposed on `RunView.instanceId`, and how the thread's owner rule finds the instance, [thread-admission.md](thread-admission.md) item 9; the consuming run's `input` event may carry `mode` and `consumed` — the thread events it folded, record 0051's mode-as-receipt rule; a HOSTED ship parent's own record ([record 0060](../../decisions/0060-a-ship-pipeline-is-a-live-run-for-its-whole-life-and-runs-on-every-channel-that-can-open-a-thread.md)) carries TWO `run_meta` events — the dispatch's, then the hand-off's naming the runner instance in `run_meta.instanceId` — plus the `ship_unit` facts the runner's routes published ([agent-ship.md](agent-ship.md) item 17): every reader resolves such a run's instance from the LAST `run_meta` carrying one, and both event kinds pass the validator like any other) with `status ∈ completed | stopped_soft | stopped_hard | failed | interrupted` and `channelVisibility ∈ public | private | dm | machine | unknown` — the channel's visibility as the `ChannelDirectory` reported it at dispatch ([authorization.md](authorization.md) item 7), what `member-of` reads; a record written before the stamp existed is accepted and reads as `unknown` (`normalizeStored`), never public — `interrupted` marks a run cut down before finish (container replaced or crashed; the provisional tombstone and the drain-deadline write of item 27 are its only writers), and `provisional?: true` is those two writers' stamp on the record itself (item 27): absent on every final write, so a store-only reader tells "no finish recorded yet" from a real interrupt that owns no live row. `eventCount` is what the run published; `storedEventCount` is `events.length`; `truncated` says the two differ (the registry backlog or the byte budget dropped events). Every stored event keeps the registry's `seq` (`StoredRunEvent`; `storedEventSeqs` falls back to positions only for a record whose events arrived without strictly increasing stamps). `RunListItem` is the record minus `events` plus an optional `bytes` (the stored JSON size) — `diagnosis` stays on the list item so the friction ledger can be served from a listing without loading events. How the run's preset was chosen rides the stream ([routing-and-config.md](routing-and-config.md) item 21): `run_meta.agentSource` — `directive`, `sticky`, `user`, `channel`, `default` or `route` — on every run since the router landed (absent on older records and on a command run), and a `route { preset, reason, model, parts?, collapsed?, command?, input?, receipt?, outcome?, handBackRunId? }` event right after `run_meta` on a run the router answered (head material, a side fact like `pr_opened`, never a step) — on a command run the router bound from prose ([routing-and-config.md](routing-and-config.md) item 21's command menu) `preset` is `command`, `command` the id the model called, `input` the bound `{ args, options }` with every string redacted and cut at `ROUTE_COMMAND_VALUE_CAP`, `receipt` the chat form the reply led with, redacted and cut at `ROUTE_RECEIPT_CAP`; how the invoke ended is the run's own `status` and `answer`; and, on a door decision about a state change ([record 0044](../../decisions/0044-a-routed-write-is-confirmed-in-proportion-to-its-blast-radius.md)), `outcome`: `hand_back` on the record of a routed write the door handed back — a command run that invoked nothing, `completed`, its `answer` the line to paste, written by `recordRoutedDecision` with no run signalled to the channel — `offered` on the record of the same decision on a channel that can show a confirmation ([routing-and-config.md](routing-and-config.md) item 25) — a command run that invoked nothing, `completed`, its `answer` the offer as the channel shows it (the full line and the risk line), the row minted in the config object — `confirmed` on the record of the stored input run at the click, as the requester, through the typed line's path (`reason: confirmed after offer`, `source: confirm` on the audit line; its status and `answer` the command's own, as a routed read's are), and `pasted` on the record of the typed line that followed a hand-back in the same thread with the same capped receipt, whatever the command, `handBackRunId` naming the hand-back's record ([routing-and-config.md](routing-and-config.md) item 21). A sixth outcome, `refused`, on the record of a routed decision a gate refused after the bind ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md)): written by `recordRoutedDecision` from the confirm path when the store's refusal still names the row ([routing-and-config.md](routing-and-config.md) item 25: `expired`, `foreign`) — a command run that invoked nothing, `completed`, `reason: refused after offer`, its `answer` the refusal's named line, the event carrying the row's command, input and receipt and `refusalCode` (a code from `src/core/refusal.ts`) — and the door report counts such records per day, cause and code ([load-harness.md](load-harness.md) item 19). Every other refusal is a run record too ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md), as amended): a gate refusal before any command is bound and before admission, a silent refusal and the catch-all's `uncaught` alike leave one record with agent `door` — written by `recordRefusal` beside the rendered sentence, the refusal's code leading the label, status `completed`, no surface told of the run and no thread claimed — whose events are the redacted request as `input` and exactly one `refusal { code, cause, text }`, the sentence redacted and capped at `ROUTE_RECEIPT_CAP`, the cause the one table's for the code; a message without a thread of its own records with the channel as its thread key, a refusal that already recorded through `recordRoutedDecision` records once, and a refused click on a question's stored proposal records the proposal as its request; a `used` click and an unreadable store name no row and leave no record — there is no message to record — and stay countable from the trace's `refusal`/`cause` root attributes ([tracing.md](tracing.md) item 3), which every refusal still carries unchanged. A run a question's Yes redispatched ([record 0054](../../decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md); [routing-and-config.md](routing-and-config.md) item 21) names the question on its record: a `run_note { kind: redispatch, summary: "confirmed after question " }` published before the first turn, `` the refusal code the stored row carried, so the Yes-run is traceable to the question whose proposal it ran. Which command runs are recorded: the inline-run commands (`isInlineRunCommand`, the ones that do work) and every routed decision whose `route` carries an `outcome`; a typed no-work command outside a paste, and a routed read of one, leave no record, as before: `parts` on a compound routed to the conductor — one `{ preset, text }` per child the brief told it to spawn, the text redacted and capped as the child's whole prompt — or `collapsed: { presets }` on the write run a compound answer collapsed onto ([routing-and-config.md](routing-and-config.md) item 21: a write ask is never a part, so the answer became one route to that preset), the preset each part named in answer order and no `parts`, and, for a compound the parse refused, the event on the run that fell to the default, `preset` being `defaults.agent` and `reason` reading `compound_rejected: ` while `agentSource` stays `default` ([routing-and-config.md](routing-and-config.md) item 21); the replay (`load route`, [load-harness.md](load-harness.md) item 17) reads both to tell a requester's own choice from a fallback and to leave the router's own answers out of its labels. The decision itself also rides the record as `route? { preset, reason, model, parts?, collapsed? }` ([routing-and-config.md](routing-and-config.md) item 21): the run's own route — folded at the one record assembly from the dispatcher's decision, or from the `route` event when `run_meta.agentSource` is `route` (`routeOfEvents`) — or, on a sticky-by-transcript follow-up that continued a routed thread, the thread's decision carried forward with no event; on the record (and its `RunListItem` and `RunView`) so the next follow-up's card can keep its route note without reading the events. +3. **Structural validator.** `isRunRecord(x)` accepts only an object whose `id` matches `RUN_ID_PATTERN` (`^[A-Za-z0-9_-]{1,64}$`), whose identity/timing/count fields have the right types, whose `status` is one of the five, whose `events` is an array of objects each with a string `type` (the union grows over time — an older reader must still accept a newer record, and the run-page fields `callId`/`exitCode`/`output`/`source` ride through verbatim), and whose `diagnosis` is structurally a diagnosis (`isStoredDiagnosis`: a `byCategory` map of `{ count, durationMs }` totals — NOT the current category list), and whose `headSha`, when present, is 7 to 40 lowercase hex, and whose `verdict`, `reviewHead` and `dispositions`, when present, have their stored shapes (`isReviewVerdictShape`, the head pattern, `isFindingDispositionsShape` — shape only, no bounds, like the handoff), and whose `handoff`, when present, has the handoff's shape (`isHandoffShape`, [agent-ship.md](agent-ship.md) item 14: three lists of string-field entries — shape only, no bounds, because redaction may lengthen a stored string), and whose `provisional` and `hosted`, when present, are each the literal `true` (item 27's persisted stamp and record 0065's hosted marker; any other value is a malformed record), and whose `pipeline`, when present, has the summary's shape (`isPipelineSummaryShape` — shape only, so an older reader accepts a newer record's field). Readers zero-fill categories a stored diagnosis lacks and drop unknown ones (`normalizeDiagnosis`), so adding a friction category never invalidates a stored record. Anything else — `null`, a bad id, an unknown status, an event without a `type` — is rejected. `isRunListItem` is the same check minus events, plus an optional numeric `bytes`. 4. **Retention policy.** `RetentionPolicy = { retentionDays, maxRuns, maxBytes }`, default `{ 30, 5000, 2 GiB }`. `clampRetentionPolicy(partial)` fills missing fields from the defaults, replaces non-finite values with the defaults, floors fractions, and clamps into `RETENTION_BOUNDS`: `retentionDays [1, 365]`, `maxRuns [1, 20000]`, `maxBytes [16 MiB, 8 GiB]`. 5. **One retention function.** `applyRetention(items, policy, nowMs)` drops items with `finishedAt < nowMs − retentionDays`, keeps the newest `maxRuns` by `finishedAt` desc (tie-break `id` desc — a total order, so bot and Worker cut identical rows), then drops the oldest while the cumulative `bytes` of the kept set exceeds `maxBytes` (missing `bytes` counts as 0). Returns the kept items newest-first and never mutates its input. 6. **Byte budget.** `fitRecordToBudget(record, maxBytes = MAX_RECORD_BYTES = 1.5 MiB)` first caps each event to `MAX_EVENT_BYTES` (64 KiB) by truncating its `text` (input/context/assistant/answer events) or `summary` (tool/note events) with a trailing `…`, measured in UTF-8 bytes. If the record is still over budget, span records go first ([tracing.md](tracing.md) — spans displace no content): pair by pair (both records of one `spanId`) from the middle of the stream outward, never from the protected head (the leading run of head material), until the record fits or no span outside the head remains; a dropped `tool.*`/`mcp.*` twin is re-synthesized by `normalizeSpans` from its content pair. Only if that was not enough are content events dropped from the middle: a head and a tail are grown alternately from the two ends (head first) until the next event would not fit, so the request/context/first tool steps and the terminal notes survive. The result has `truncated: true`, `eventCount` unchanged, `storedEventCount = events.length`; an under-budget record comes back with its events intact and `truncated` untouched. The source record is never mutated. @@ -117,6 +117,7 @@ Every [run](../vocabulary.md#run) becomes a durable record — identity, timing, | 46: the registry projects `RunMeta.parentRunId` onto the summary and the index feed, and omits it when absent | `[unit]` `src/core/runRegistry/projections.test.ts::RunRegistry.snapshot — record inputs::carries parentRunId from the RunMeta…` | | 46: `RunsService` views carry it live and persisted, and a listing shows the parent of every child | `[unit]` `src/core/runsService.test.ts::RunsService.getRun::carries parentRunId from a live child's RunMeta…` | | 2: a direct decision-record task's re-issue pages through retained coding records until its `recordTaskKey` is found beyond the newest 200 rows, then reuses that `record` | `[unit]` `src/core/dispatcher.test.ts::coding PR post-step (docs/reference/specs/pr-description.md)::a re-issued decision-record task finds its reservation beyond the newest 200 coding records` | +| 2: the independently observed final workspace head persists as `headSha` at finish, validates and is absent without a readable Git head; production `read-record` returns it beside the handoff and pushed heads, so a completed same-head salvage opens the pull request and enters review while a differing returned head remains a checkpoint | `[unit]` `src/core/dispatch/record.test.ts::assembleRunRecord — the handoff on the record::carries the independently observed final workspace head, validates it, and omits it when the workspace had no head`; `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a coding child's final description turn checkpoints dirty work before release`; `src/channels/adminCoordinator.test.ts::POST /admin/coordinator/read-record — the renewal's facts off the record (decision 0046)::a completed child's production read-record view carries its observed final head, so same-head salvage opens the pull request and enters review`, `::a completed child whose salvage differs from the head returned by production read-record still aborts at the checkpoint` | | 2: the pushed heads — one per branch, the last sha winning — fold onto the record off `pushed_head` events and validate, none → no key; the event parses with a ref, a sha and who pushed it, and the run timeline and the card carry it | `[unit]` `src/core/runRecord.test.ts::*::pushedHeadsOf lists the run's pushed heads…`, `src/core/dispatch/record.test.ts::*::carries the run's pushed heads off its pushed_head events…`, `src/core/runEventLines.test.ts::parseRunEventLines::accepts \`pushed_head\`…`, `src/channels/runTimeline.test.ts::createRunTimeline — grouping::passes input / answer / run_note through…`, `src/core/dispatch/reply.test.ts::*::every other event is its activityLine as a line part` | | 2: the compaction checkpoint's push is a fact of the run — a `pushed_head` event (`by: "salvage"`) on the record beside its `compaction_salvage` note, so a renewal reads the checkpointed head off the row | `[unit]` `src/core/dispatch/runLoop.test.ts::the pi harness — every preset's runs, in the run's container::a coding child whose compaction fails for good checkpoints its tree…` | | 2: a run a question's Yes redispatched carries the redispatch note — `confirmed after question `, the stored row's refusal code — published before the first turn | `[unit]` `src/core/dispatcher.test.ts::the question's Yes: the guess is stored and one click redispatches it (record 0054)::Yes consumes the row and hands the proposal to dispatch() as the requester…` | diff --git a/src/channels/adminCoordinator.test.ts b/src/channels/adminCoordinator.test.ts index 3fb4c9fef..12dab95c5 100644 --- a/src/channels/adminCoordinator.test.ts +++ b/src/channels/adminCoordinator.test.ts @@ -7,7 +7,14 @@ import { NO_GRANTS, type Grants } from "../core/authz/types.js"; import { InMemoryCoordinatorInstanceStore } from "../core/coordinator/instanceStore.js"; import type { CoordinatorInstance, CoordinatorTag, CoordinatorUnit } from "../core/coordinator/contract.js"; import type { ChildContract } from "../core/ship/contract.js"; -import type { RoundChecks } from "../core/ship/coordinator.js"; +import { + applyReturn, + nextAction, + openUnitPipeline, + type ChildFacts, + type RoundChecks, + type StepReturn, +} from "../core/ship/coordinator.js"; import type { DispatchOutcome } from "../core/dispatch/outcome.js"; import { REPLAY_EVERYTHING, RunRegistry } from "../core/runRegistry.js"; import { InMemoryRunStore } from "../core/runStore.js"; @@ -1297,6 +1304,104 @@ describe("POST /admin/coordinator/read-record — the renewal's facts off the re expect(unpriced.run.pushed).toBeUndefined(); }); + const salvageScenario = async (observedHead: string, salvageHead: string) => { + const branch = INSTANCE.branch; + const h = harness({ openPr: { number: 77, htmlUrl: "https://github.com/acme/api/pull/77", created: true } }); + const child = h.registry.create("coding · child", { + agent: "coding", + channelId: INSTANCE.channelId, + userId: INSTANCE.userId, + threadKey: INSTANCE.threadKey, + ...TAG, + }); + h.registry.finish(child.id, "completed"); + const runId = child.id; + await h.instances.put(INSTANCE); + await h.instances.putUnits([ + { + instanceId: INSTANCE.id, + unit: "U12", + slug: "u12", + title: "Warm the cache on wake", + branch, + dependsOn: [], + rounds: [], + }, + ]); + await h.store.put( + record(runId, { + ...TAG, + handoff: { deviations: [], followUps: [], unproven: [] }, + pushed: [{ ref: branch, sha: salvageHead, by: "salvage" }], + headSha: observedHead, + }), + ); + h.registry.markPersisted(runId); + + const driver = { + state: openUnitPipeline( + { + unit: { id: "U12", branch }, + repo: INSTANCE.repo, + base: INSTANCE.base!, + caps: { maxRounds: 3, maxMinutes: 240 }, + merge: "person", + generated: false, + }, + NOW - 60_000, + ), + }; + const feed = (answer: Record) => { + const action = nextAction(driver.state); + if (action.type === "end") throw new Error("the unit ended before the scripted answer"); + driver.state = applyReturn(driver.state, { ...answer, step: action.step } as StepReturn).state; + }; + feed({ type: "pr-check", pr: { state: "none" }, at: NOW - 60_000 }); + feed({ type: "branch", ok: true, at: NOW - 59_000 }); + feed({ type: "spawn", outcome: "spawned", runId, at: NOW - 58_000 }); + feed({ type: "wait", outcome: "event" }); + + const read = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}read-record`, { parentInstanceId: INSTANCE.id, runId, unit: "U12" }), + h.deps, + ); + expect(read.status).toBe(200); + const run = (read.body as { run: ChildFacts }).run; + expect(run).toMatchObject({ headSha: observedHead, handoff: true, pushed: [{ sha: salvageHead, by: "salvage" }] }); + feed({ type: "read-record", run, at: NOW }); + return { h, driver, feed, runId }; + }; + + it("a completed child's production read-record view carries its observed final head, so same-head salvage opens the pull request and enters review", async () => { + const HEAD = "a".repeat(40); + const { h, driver, feed, runId } = await salvageScenario(HEAD, HEAD); + const recover = nextAction(driver.state); + expect(recover).toMatchObject({ type: "pr-check", recover: { runId } }); + if (recover.type !== "pr-check") throw new Error("expected the pull-request recovery step"); + const opened = await handleCoordinatorRequest( + post(`${COORDINATOR_ADMIN_PREFIX}pr-check`, { + parentInstanceId: INSTANCE.id, + unit: "U12", + ...(recover.recover !== undefined ? { recover: recover.recover } : {}), + }), + h.deps, + ); + expect(h.opens).toHaveLength(1); + expect(opened.body).toMatchObject({ ok: true, state: "open", prNumber: 77 }); + feed({ type: "pr-check", pr: opened.body, at: NOW }); + expect(nextAction(driver.state)).toMatchObject({ + type: "spawn", + preset: "review", + round: { index: 1, kind: "review" }, + }); + }); + + it("a completed child whose salvage differs from the head returned by production read-record still aborts at the checkpoint", async () => { + const { h, driver } = await salvageScenario("b".repeat(40), "a".repeat(40)); + expect(nextAction(driver.state)).toMatchObject({ type: "end", ending: { kind: "aborted" } }); + expect(h.opens).toHaveLength(0); + }); + // Issue 1932: the failure by name rides the answer, so the machine can tell // a provider transient from the child failing on its task. it("answers a failed child's failure by name (`provider_transient`), and leaves the field off a record without one", async () => { diff --git a/src/channels/adminCoordinator.ts b/src/channels/adminCoordinator.ts index 34da95621..476b9cb11 100644 --- a/src/channels/adminCoordinator.ts +++ b/src/channels/adminCoordinator.ts @@ -539,6 +539,8 @@ export interface CoordinatorRunView { /** The generation driving a live run elsewhere (run-history item 41). */ ownerGen?: string; finalReply?: string; + /** The final Git head independently observed by the run loop. */ + headSha?: string; } type Parsed = { ok: true; value: T } | { ok: false; error: string }; @@ -1025,6 +1027,7 @@ function coordinatorRunView( ...(view.idempotencyKey !== undefined ? { idempotencyKey: view.idempotencyKey } : {}), ...(view.ownerGen !== undefined ? { ownerGen: view.ownerGen } : {}), ...(finalReply !== undefined ? { finalReply } : {}), + ...(view.headSha !== undefined ? { headSha: view.headSha } : {}), }; } diff --git a/src/core/dispatch/record.test.ts b/src/core/dispatch/record.test.ts index d3a3bd333..7ed695ead 100644 --- a/src/core/dispatch/record.test.ts +++ b/src/core/dispatch/record.test.ts @@ -226,6 +226,15 @@ describe("assembleRunRecord — the handoff on the record", () => { for (const key of ["verdict", "reviewHead", "dispositions"]) expect(key in record).toBe(false); }); + it("carries the independently observed final workspace head, validates it, and omits it when the workspace had no head", () => { + const headSha = "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678"; + const record = assembleRunRecord({ ...base(), headSha }); + expect(record).toMatchObject({ headSha }); + expect(isRunRecord(JSON.parse(JSON.stringify(record)))).toBe(true); + expect("headSha" in assembleRunRecord(base())).toBe(false); + expect(isRunRecord({ ...record, headSha: "not-a-commit" })).toBe(false); + }); + it("carries the decision-record reservation and direct-task key from run_meta", () => { const record = assembleRunRecord({ ...base(), diff --git a/src/core/dispatch/record.ts b/src/core/dispatch/record.ts index ec27af090..26aa26919 100644 --- a/src/core/dispatch/record.ts +++ b/src/core/dispatch/record.ts @@ -310,6 +310,9 @@ export function assembleRunRecord(input: { * and seal are appended, the published total takes the larger count, and the * two seal stamps ride the record — omitted when the seal has none. */ seal?: SealResult; + /** The final workspace head the run loop observed independently of the + * model and push events. Omitted when this run had no readable Git head. */ + headSha?: string; /** The typed handoff the run submitted (docs/reference/specs/agent-ship.md item 14), * as the tool accepted it; redacted HERE, the one assembly, so no caller * can forget. Omitted (not set undefined) when the run submitted none. */ @@ -420,6 +423,7 @@ export function assembleRunRecord(input: { ...(referencesOfEvents(events).length > 0 ? { references: referencesOfEvents(events) } : {}), ...(msg.sourceUrl !== undefined ? { sourceUrl: msg.sourceUrl } : {}), ...(msg.userName !== undefined ? { userName: msg.userName } : {}), + ...(input.headSha !== undefined ? { headSha: input.headSha } : {}), ...(input.handoff !== undefined ? { handoff: redactHandoff(input.handoff) } : {}), ...(input.verdict !== undefined ? { verdict: redactVerdict(input.verdict) } : {}), ...(input.reviewHead !== undefined ? { reviewHead: input.reviewHead } : {}), @@ -572,6 +576,8 @@ export interface FinishRecordContext { diagnosis: FrictionDiagnosis; root: Span; ledgerRun: LedgerRun | undefined; + /** The final Git head the run loop observed after its tail settled. */ + headSha?: string; /** The handoff the run loop captured from `submit_handoff`, when one was submitted. */ handoff?: Handoff; /** The verdict a review run submitted and the head it reviewed; the dispositions a fix round submitted. */ @@ -617,6 +623,7 @@ export function registerFinishRecord(deps: RecordDeps, ctx: FinishRecordContext) diagnosis, root, ledgerRun, + headSha, handoff, verdict, reviewHead, @@ -648,6 +655,7 @@ export function registerFinishRecord(deps: RecordDeps, ctx: FinishRecordContext) status: failedAfterFinish && status === "completed" ? "failed" : status, diagnosis, seal, + ...(headSha !== undefined ? { headSha } : {}), ...(handoff !== undefined ? { handoff } : {}), ...(verdict !== undefined ? { verdict } : {}), ...(reviewHead !== undefined ? { reviewHead } : {}), diff --git a/src/core/dispatch/runLoop.test.ts b/src/core/dispatch/runLoop.test.ts index 6b8901f1e..b0d58827d 100644 --- a/src/core/dispatch/runLoop.test.ts +++ b/src/core/dispatch/runLoop.test.ts @@ -1091,7 +1091,7 @@ describe("runLoop — the model turn and everything that rides on it", () => { s.ending.drain(undefined); await s.writer.settled(); const rec = (await s.store.get("run-l"))!; - expect(rec.pushed).toEqual([{ ref: BRANCH, sha: HEAD, by: "salvage" }]); + expect(rec).toMatchObject({ headSha: HEAD, pushed: [{ ref: BRANCH, sha: HEAD, by: "salvage" }] }); expect(rec.events).not.toContainEqual(expect.objectContaining({ type: "run_note", kind: "work_left_behind" })); }); diff --git a/src/core/dispatch/runLoop.ts b/src/core/dispatch/runLoop.ts index acb7a9a3c..d9513dab9 100644 --- a/src/core/dispatch/runLoop.ts +++ b/src/core/dispatch/runLoop.ts @@ -1921,6 +1921,7 @@ export async function runLoop(deps: RunDeps, ctx: RunLoopContext): Promise { + const d = fresh(input({ merge: "person" })); + d.answer({ type: "branch", ok: true, at: T0 }); + runChild( + d, + "run-c0", + finished({ + status: "stopped_soft", + handoff: true, + headSha: HEAD_A, + pushed: [{ ref: d.state.input.unit.branch, sha: HEAD_A, by: "salvage" }], + }), + T0 + 5 * MIN, + ); + expect(d.action).toMatchObject({ + type: "end", + ending: { kind: "stopped", checkpoint: { branch: d.state.input.unit.branch, sha: HEAD_A } }, + }); + }); + it("a completed coding child whose ending checkpoint reached the unit branch aborts with the resumable head", () => { const d = fresh(input({ merge: "person" })); d.answer({ type: "branch", ok: true, at: T0 }); diff --git a/src/core/ship/coordinator.ts b/src/core/ship/coordinator.ts index 25bb45e8e..525e7dcfe 100644 --- a/src/core/ship/coordinator.ts +++ b/src/core/ship/coordinator.ts @@ -1211,6 +1211,10 @@ type Phase = childPushed?: PushedHeadFact[]; childLeaseStartedAt?: number; childHandoff?: Handoff; + /** Open or recover a pull request from the branch. A completed child + * whose same-head salvage merely republished ready work needs this path + * without being classified as dead. */ + recover?: true; /** The coding child died (`failed` or `interrupted`) after it may have * pushed: the pr-check recovers a pushed branch by opening its pull * request; with nothing pushed the unit ends with the child's own reason. */ @@ -1557,7 +1561,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction { return { type: "pr-check", step: `${roundStep(s, p.round)}/pr-check`, - ...(p.dead !== undefined ? { recover: { runId: p.runId } } : {}), + ...(p.dead !== undefined || p.recover === true ? { recover: { runId: p.runId } } : {}), // The adopted pull request rides the check so the bot can follow it // when nothing heads the unit's branch (issue 1799). ...(s.pr !== undefined ? { pr: s.pr.number } : {}), @@ -1810,14 +1814,37 @@ function nextReview(s: UnitPipelineState, notes: CoordinatorNote[] = []): Transi const stopMode = (status: RunStatus): "soft" | "hard" | undefined => status === "stopped_soft" ? "soft" : status === "stopped_hard" ? "hard" : undefined; +/** The last mechanical salvage push to this unit branch. */ +function salvagePush(s: UnitPipelineState, pushed: readonly PushedHeadFact[] | undefined): PushedHeadFact | undefined { + return pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1); +} + +/** A completed child independently witnessed at the same head and carrying its + * handoff already declared the work ready; salvage merely republished its commit. */ +function completedSameHeadSalvage( + s: UnitPipelineState, + pushed: readonly PushedHeadFact[] | undefined, + child?: { status: RunStatus; handoff?: boolean; headSha?: string }, +): boolean { + if (child === undefined) return false; + const found = salvagePush(s, pushed); + const salvageHead = normalizeHead(found?.sha); + const childHead = normalizeHead(child.headSha); + return ( + child.status === "completed" && child.handoff === true && salvageHead !== undefined && salvageHead === childHead + ); +} + /** The last mechanical WIP push to this unit branch. Unlike an ordinary push, - * it says the child ended before its work was ready for review. */ + * it says the child ended before its work was ready for review. */ function interruptedCheckpoint( s: UnitPipelineState, pushed: readonly PushedHeadFact[] | undefined, + child?: { status: RunStatus; handoff?: boolean; headSha?: string }, ): { branch: string; sha: string } | undefined { - const found = pushed?.filter((p) => p.ref === s.input.unit.branch && p.by === "salvage").at(-1); - return found ? { branch: found.ref, sha: found.sha } : undefined; + const found = salvagePush(s, pushed); + if (found === undefined || completedSameHeadSalvage(s, pushed, child)) return undefined; + return { branch: found.ref, sha: found.sha }; } /** A coding run's confirmed end: round 0's child, or the run a findings step dispatched. */ @@ -1847,7 +1874,8 @@ function settleCoding( } : {}), }; - const checkpoint = interruptedCheckpoint(next, facts.pushed); + const readySalvage = completedSameHeadSalvage(next, facts.pushed, facts); + const checkpoint = interruptedCheckpoint(next, facts.pushed, facts); const mode = stopMode(facts.status); if (mode !== undefined) return end( @@ -1909,6 +1937,7 @@ function settleCoding( round, runId, ...(facts.headSha !== undefined ? { childHead: facts.headSha } : {}), + ...(readySalvage ? { recover: true as const } : {}), ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}), ...(facts.pushed !== undefined ? { childPushed: facts.pushed } : {}), ...(facts.leaseStartedAt !== undefined ? { childLeaseStartedAt: facts.leaseStartedAt } : {}),