Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e51f9df
F: wire the runner and startup recovery into the server (#91, part 1)
mchwang Oct 1, 2026
b2acf8b
Close the gaps review round 1 found in the runner wiring (#91)
mchwang Oct 1, 2026
1e115c3
Close the gaps review round 2 found in the runner wiring (#91)
mchwang Oct 1, 2026
a5bb143
Review round 3: the runner view offers retry only when admission woul…
mchwang Oct 1, 2026
4848786
Code review (high) round 1 fixes for the runner wiring (#91)
mchwang Oct 2, 2026
8d1052a
Code review (high) round 2 fixes for the runner wiring (#91)
mchwang Oct 2, 2026
3da01d8
Code review (high) round 3: demos get their own runner refusal (#91)
mchwang Oct 2, 2026
55d3d5c
Merge remote-tracking branch 'origin/main' into feat/f-runner-wiring-91
mchwang Oct 2, 2026
cc84aa8
F: start and resume a task's plan through /api/runner (#91, part 2)
mchwang Oct 2, 2026
a3e0a3a
Close the gaps review round 1 found in start and resume (#91)
mchwang Oct 3, 2026
ed14125
Close the gaps review round 2 found in start and resume (#91)
mchwang Oct 3, 2026
9ea6e1b
Review round 3: one start/resume rule in code and docs (#91)
mchwang Oct 3, 2026
fb10295
Review round 4: tests for the start rule, doc precision (#91)
mchwang Oct 3, 2026
ccbd9a2
Merge origin/main into feat/f-runner-wiring-91
mchwang Oct 3, 2026
800fbcf
Merge branch 'feat/f-runner-wiring-91' into feat/f-runner-start-91
mchwang Oct 3, 2026
b210311
Status polls read run progress with one targeted query (#91)
mchwang Oct 3, 2026
d691236
Code review: refuse a runner root Docker cannot mount; check the sche…
mchwang Oct 3, 2026
3fb8910
Merge branch 'feat/f-runner-wiring-91' into feat/f-runner-start-91
mchwang Oct 3, 2026
6858c2b
Address Copilot review on #102 (#91)
mchwang Oct 3, 2026
6c43115
Merge branch 'feat/f-runner-wiring-91' into feat/f-runner-start-91
mchwang Oct 3, 2026
15db8db
Address Copilot review round 2 on #102 (#91)
mchwang Oct 3, 2026
f802b7d
Merge branch 'feat/f-runner-wiring-91' into feat/f-runner-start-91
mchwang Oct 3, 2026
3f18b0c
Code review round 1 on #105: count every writable kind's earlier comm…
mchwang Oct 3, 2026
997b63e
Merge main (#102 squashed as 80ac5f2) into feat/f-runner-start-91
mchwang Oct 3, 2026
8e0a84e
Code review round 2 on #105: point a running task's start to resume; …
mchwang Oct 3, 2026
0a78b97
Code review round 3 on #105: 503 before any start/resume refusal at s…
mchwang Oct 3, 2026
d1eda14
Code review round 4 on #105: test the view between items; refusal mes…
mchwang Oct 3, 2026
4bd51d7
Code review round 5 on #105: wording and docs (#91)
mchwang Oct 3, 2026
2c78519
Fix the three Copilot findings deferred from #102 (#91)
mchwang Oct 3, 2026
ea7136b
Address Copilot review on #105: serializer pre-check, terminal quotin…
mchwang Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ A configuration with a `github` block may also add a `runner` block. The runner
- `diagnosticsCapBytes`: the size retention trims that directory back to. It is a target, not a hard limit: the file just saved is always kept, even when it alone passes the cap. The default is 256 MiB.
- `limits`: task storage limits (`workBytes`, `workInodes`, `metadataBytes`, `metadataInodes`).

The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. Nothing in the review screen starts a run yet (#91 part 2).
The runner needs Docker and `CLAUDE_CODE_OAUTH_TOKEN`. At start, before the server opens, codeboost recovers what an earlier run left and builds the agent image; a recovery it cannot finish safely stops startup with what to do. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no buttons for them yet.

## Library

Expand Down
12 changes: 9 additions & 3 deletions docs/implementation/runner-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,17 @@ This runs before the coordinator opens.
7. **Unowned preparation check.** If any attempt has `preparation_started_at` set and `preparation_pgid` null (see "Launch"), stop here: do not open the coordinator, name each attempt and directory, and wait for `--release-preparation`. Step 4 does not remove those directories.
8. Open the coordinator.

**Production wiring (#91).** The runner is opt-in: the review configuration's `runner` block names its root, an optional diagnostics directory and cap, the committer and optional storage limits (`runner/production.ts`, `parseRunnerConfig`). Without the block, or in a demo, there is no coordinator and runner actions answer that the runner is not configured. With it, `web/cli.ts` takes the lock, `startServer` opens the `Store` and then, before it listens, runs `setUpRunner`: it checks for the github block (the prompt carries the issue) and `CLAUDE_CODE_OAUTH_TOKEN`, calls the lock's `verify()` (decision 1, step 4), reads the runner token for the locked file, and runs `recoverStartup` with D's own `recoverLeftovers`, `exportTaskDiff` and `removeTaskFilesystemsAsync`. The agent image is built after D's recovery has stopped every leftover agent, so no agent keeps writing to its storage during a long build, and before the first export's deadline starts (the build blocks the event loop, so a deadline armed before it would expire as soon as it returned). Any failure closes the `Store`, releases the lock and exits with its message. To export a recovered storage, F saves the storage's `metadataBaseline` and the commit it was seeded from (`attempts.metadata_baseline`, `attempts.storage_base`, schema v9) as soon as D's allocation returns; a row without them gets "Partial output could not be exported: its storage baseline was never saved". Diagnostics are kept per database, in `<diagnosticsDir or root>/<runnerOwner>/diagnostics`: retention reads references only from its own `Store`, so runners of databases sharing a directory must never see each other's files. Recovered diffs are saved through `saveDiagnostic`, with the same owner-only file and retention as the live path; retention keeps every diff this recovery saved, because none is referenced until the finalization transaction. A diff D cut at its byte limit ends with a `codeboost:` line that says so, on this path and the live one. The issue text a prompt carries (title, body and collaborators' comments) is read once and reused for later items for up to five minutes; the base tree listing is read once per base commit. A first Ctrl+C during startup waits for startup to finish; a second stops the process at once, which recovery treats like a crash at the next start. The deps accept execute attempts only (`RunnerDeps.kinds`): admission refuses any other kind before it writes anything. A plan item runs only through `ItemExecutor`, so `/api/runner` refuses to retry an execute attempt on its own; resuming a task comes in #91 part 2.
**Production wiring (#91).** The runner is opt-in: the review configuration's `runner` block names its root, an optional diagnostics directory and cap, the committer and optional storage limits (`runner/production.ts`, `parseRunnerConfig`). Without the block, or in a demo, there is no coordinator and runner actions answer that the runner is not configured. With it, `web/cli.ts` takes the lock, `startServer` opens the `Store` and then, before it listens, runs `setUpRunner`: it checks for the github block (the prompt carries the issue) and `CLAUDE_CODE_OAUTH_TOKEN`, calls the lock's `verify()` (decision 1, step 4), reads the runner token for the locked file, and runs `recoverStartup` with D's own `recoverLeftovers`, `exportTaskDiff` and `removeTaskFilesystemsAsync`. The agent image is built after D's recovery has stopped every leftover agent, so no agent keeps writing to its storage during a long build, and before the first export's deadline starts (the build blocks the event loop, so a deadline armed before it would expire as soon as it returned). Any failure closes the `Store`, releases the lock and exits with its message. To export a recovered storage, F saves the storage's `metadataBaseline` and the commit it was seeded from (`attempts.metadata_baseline`, `attempts.storage_base`, schema v9) as soon as D's allocation returns; a row without them gets "Partial output could not be exported: its storage baseline was never saved". Diagnostics are kept per database, in `<diagnosticsDir or root>/<runnerOwner>/diagnostics`: retention reads references only from its own `Store`, so runners of databases sharing a directory must never see each other's files. Recovered diffs are saved through `saveDiagnostic`, with the same owner-only file and retention as the live path; retention keeps every diff this recovery saved, because none is referenced until the finalization transaction. A diff D cut at its byte limit ends with a `codeboost:` line that says so, on this path and the live one. The issue text a prompt carries (title, body and collaborators' comments) is read once and reused for later items for up to five minutes; the base tree listing is read once per base commit. A first Ctrl+C during startup waits for startup to finish; a second stops the process at once, which recovery treats like a crash at the next start. The deps accept execute attempts only (`RunnerDeps.kinds`): admission refuses any other kind before it writes anything. A plan item runs only through `ItemExecutor`, so `/api/runner` refuses to retry an execute attempt on its own; a task's plan runs again through `resume` (below).

## HTTP and UI contract

**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved; the host-side preparation files could not be removed (before the terminal write when D never ran, after it once D settled); or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review.
**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `available`, `stateVersion`, `retryable`, `startable`, `resumable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved; the host-side preparation files could not be removed (before the terminal write when D never ran, after it once D settled); or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review.

**Start and resume (#91 part 2).** `POST /api/runner` with `action: "start"` or `"resume"` sends `expectedStateVersion` and an `actionId`, but no attempt ID. Both run the plan through `ItemExecutor`, item by item. Everything up to the first item's admission happens inside the user action's transaction, so a refusal rolls back the action's own writes.
- `start` runs a task that is `in review` or `queued`, has no execute attempt at its current plan revision, and has no pending requeue (that is `resume`'s). An `in review` task moves to `queued` in the same action.
- `resume` continues a task that is `running` or `queued` and has started running (it has an execute attempt at any plan revision, or recovery left it to requeue). It starts at the first item with no completed execute attempt at the current revision, whether the last item completed, failed or was stopped, and claims `requeue_pending` in the admitting transaction. This is also how a task stopped between items continues.
- Both refuse a closed task, a pending cancel, a runner that cannot run execute attempts, an active attempt, an earlier run still finishing, an active merge, a scope pause (#88), and a plan revised after an earlier revision's items made runner commits (#88: those commits must be reconciled with the new revision first; earlier attempts that committed nothing do not block). During shutdown both answer 503 before any of these, and record nothing. When none of these applies, a finding or scope pause owed from an earlier run is acted on first, and nothing is admitted (`outcome: "settled"`). Only then is the first item admitted: admission refuses a spent task budget, which also moves an idle `running` or `queued` task to `needs human` (a task still `in review` stays there: its move to `queued` rolls back with the refused action).
- `GET /api/runner` adds `startable` and `resumable`, computed by the same checks, so the view never offers an action that would be refused. The view also withholds them while the task budget has run out (the action may still act on owed work, or else lets admission refuse it with its move to `needs human`) and while the runner is shutting down or needs a restart. Retrying an execute attempt is refused: a plan item runs again only through `resume`.

**User actions.** Cancel, retry and "run again" requests send `attemptId`, `expectedStateVersion` and an `actionId` idempotency key (see "Feedback-event contract"). A replayed `actionId` returns the saved outcome. The server takes the plan identity from its trusted configuration, never from the request, and every `Store` call is scoped by that identity. A mismatch returns HTTP 409 with the current state. The UI then shows that state and keeps any draft.

Expand Down Expand Up @@ -357,7 +363,7 @@ Live planning invocation waits for D5. Until then, the server returns "Planning

**What becomes an event.** Only feedback the user wrote or chose. Issue text, issue comments and agent output never become events.

`actionId` identifies the one user action (or confirmed external outcome) that caused the event. **For user actions it is an idempotency key the UI generates** (a UUID) when the user acts. The server validates it before opening any transaction: it must be a lowercase UUID v4 (the same regular expression as attempt IDs, 36 characters), or the request is refused with HTTP 400 and nothing is stored. The UI generates it and resends unchanged if it retries after a lost response. Every writing user action goes through one `user_actions` table, keyed by `(planKey, actionId)`. That covers notes, choice changes, finding acceptance, rejection, cancel, retry, "run again", **starting a merge**, and every planning endpoint that writes: plan import, suggestion start, suggestion cancel and suggestion apply. Each planning request carries an `actionId`; its `user_actions` row is inserted in the same transaction as the `Store` call it wraps, and the saved response includes the request ID or plan revision it produced. For a merge, the `user_actions` row is inserted in the same transaction as `beginMergeAttempt`, and it saves the merge attempt ID with the response "in progress". When the merge coordinator records the outcome, the same transaction updates that saved response. A replayed merge click therefore returns the same attempt's current outcome and never starts a second attempt. The row holds the action kind, a hash of the request body and the bounded response. **The first definite outcome is recorded, including a refusal.** If the action applies, its row is inserted in the same transaction. If a guard refuses it (for example HTTP 409 for a stale state version), the refusal and its response are recorded in their own transaction. Only outcomes that applied nothing for a passing reason are not recorded, so the UI may resend them: HTTP 503 during shutdown, and a storage error. A replay is handled before any other guard:
`actionId` identifies the one user action (or confirmed external outcome) that caused the event. **For user actions it is an idempotency key the UI generates** (a UUID) when the user acts. The server validates it before opening any transaction: it must be a lowercase UUID v4 (the same regular expression as attempt IDs, 36 characters), or the request is refused with HTTP 400 and nothing is stored. The UI generates it and resends unchanged if it retries after a lost response. Every writing user action goes through one `user_actions` table, keyed by `(planKey, actionId)`. That covers notes, choice changes, finding acceptance, rejection, cancel, retry, "run again", start and resume, **starting a merge**, and every planning endpoint that writes: plan import, suggestion start, suggestion cancel and suggestion apply. Each planning request carries an `actionId`; its `user_actions` row is inserted in the same transaction as the `Store` call it wraps, and the saved response includes the request ID or plan revision it produced. For a merge, the `user_actions` row is inserted in the same transaction as `beginMergeAttempt`, and it saves the merge attempt ID with the response "in progress". When the merge coordinator records the outcome, the same transaction updates that saved response. A replayed merge click therefore returns the same attempt's current outcome and never starts a second attempt. The row holds the action kind, a hash of the request body and the bounded response. **The first definite outcome is recorded, including a refusal.** If the action applies, its row is inserted in the same transaction. If a guard refuses it (for example HTTP 409 for a stale state version), the refusal and its response are recorded in their own transaction. Only outcomes that applied nothing for a passing reason are not recorded, so the UI may resend them: HTTP 503 during shutdown, and a storage error. A replay is handled before any other guard:

- the same `actionId` with the same request hash returns the saved response and applies nothing, even though the state version has since moved on;
- the same `actionId` with a different request hash (including a different action kind) is refused with HTTP 409 "Action ID already used" and applies nothing.
Expand Down
17 changes: 11 additions & 6 deletions github/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,19 @@ export class GhIssueGateway implements IssueGateway {
if (issue.number !== number) throw new Error('GitHub returned a different issue.');
if (Object.hasOwn(issue, 'pull_request')) throw new Error(`#${number} is a pull request, not an issue.`);
const title = boundedString(issue.title, 'title', 4096), body = boundedString(issue.body, 'body', MAX_BODY_LENGTH, true);
const collaborators = await this.#loadCollaborators(signal);
const comments: string[] = [];
// The execute prompt carries the issue as one JSON data block of at most MAX_PROMPT_BYTES (dataJSON). A running byte
// count stops reading early; the exact check below uses the prompt's own serializer, so an issue accepted here is
// one the prompt can carry.
// count stops reading early (a title and body already over it read no collaborator or comment page); the exact check
// below uses the prompt's own serializer, so an issue accepted here is one the prompt can carry.
const tooLong = () => new Error(`Issue #${number}'s title, body and collaborator comments are larger than the ${MAX_PROMPT_BYTES / 1024} KiB an execute prompt carries; codeboost does not cut an issue to fit.`);
// Only the size refusal is reworded; any other (text with a NUL, for one) keeps its own reason.
const carried = (text: IssueText) => {
try { dataJSON(text, 'Issue data'); } catch (error) { throw /exceeds/.test((error as Error).message) ? tooLong() : error; }
};
// The title and body alone, as the prompt serializes them (escaping included): an issue that cannot fit reads nothing more.
carried({ number, title, body, comments: [] });
let total = Buffer.byteLength(title) + Buffer.byteLength(body);
const collaborators = await this.#loadCollaborators(signal);
const comments: string[] = [];
for (let page = 1; ; page++) {
const listed = await this.run(['api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json',
`repos/${this.repository}/issues/${number}/comments`, '-f', `per_page=${PAGE_SIZE}`, '-f', `page=${page}`], { signal });
Expand All @@ -280,8 +286,7 @@ export class GhIssueGateway implements IssueGateway {
if (values.length < PAGE_SIZE) break;
}
const text = { number, title, body, comments };
// Only the size refusal is reworded; any other (text with a NUL, for one) keeps its own reason.
try { dataJSON(text, 'Issue data'); } catch (error) { throw /exceeds/.test((error as Error).message) ? tooLong() : error; }
carried(text);
return text;
});
}
Expand Down
2 changes: 2 additions & 0 deletions runner/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export interface SlotLimits { readonly writable: number; readonly readOnly: numb
export interface StartRequest {
expectedStateVersion: number; kind: AttemptKind; item?: string | null; deadline: number; budgetMs?: number; retryOf?: string;
expectedContext: AttemptRecord['context'];
/** Clears the task's requeue claim in the admitting transaction: the user's Resume of an interrupted task. */
claimRequeue?: boolean;
}
export interface RunnerStatus {
active: boolean;
Expand Down
Loading
Loading