Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2d9f232
F: publish a finished task's pull request in production (#103)
mchwang Oct 3, 2026
1edc262
F: close a cancelled task's pull requests (#111)
mchwang Oct 3, 2026
9dee0a8
F: report a cancelled task's PR recorded in another repository instea…
mchwang Oct 3, 2026
cfb38a9
F: review fixes for publishing (#103): finding before budget, one gh …
mchwang Oct 4, 2026
3201907
Merge remote-tracking branch 'origin/feat/103-publish-pr' into feat/1…
mchwang Oct 4, 2026
b6a7db7
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 4, 2026
2943381
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
80a19bd
F: retry a refused close at the opening's own deadline; test the aban…
mchwang Oct 4, 2026
b2fe796
F: settle a publish action whose process stopped before its outcome (…
mchwang Oct 4, 2026
7a17d94
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
b35a2a0
F: record every publish as in flight before it starts; settle a lefto…
mchwang Oct 4, 2026
b920536
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
4db77d3
F: owe a job an unsettled action asked for; test close order and canc…
mchwang Oct 4, 2026
1439ccf
F: pay what an earlier run owes before publishing; check the github b…
mchwang Oct 4, 2026
88beee1
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
f60ca06
F: fixes from an independent crash-point review of publishing (#103)
mchwang Oct 4, 2026
0a74136
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
111b140
F: fixes from round 2 of the independent review of publishing (#103)
mchwang Oct 4, 2026
556668b
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
699a351
F: fixes from round 3 of the independent review of publishing (#103)
mchwang Oct 4, 2026
805b85f
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
4d692d8
F: fixes from round 4 of the independent review of publishing (#103)
mchwang Oct 4, 2026
623d4c9
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
8716173
F: fixes from round 5 of the independent review of publishing (#103)
mchwang Oct 4, 2026
679fbee
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
2f8934e
F: fixes from round 6 of the independent review of publishing (#103)
mchwang Oct 4, 2026
e4a7db8
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
bb4c791
F: fixes from round 7 of the independent review of publishing (#103)
mchwang Oct 4, 2026
ad712a6
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
58fdcf4
F: fixes from round 8 of the independent review of publishing (#103)
mchwang Oct 4, 2026
d9cca68
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
d94948e
F: fixes from round 9 of the independent review of publishing (#103)
mchwang Oct 4, 2026
3431555
Merge branch 'feat/103-publish-pr' of https://github.com/codeabovelab…
mchwang Oct 4, 2026
b1720c6
Merge main (#110 squash-merged) into feat/111-close-draft-on-cancel
mchwang Oct 4, 2026
efed7a9
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 4, 2026
9c3db97
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 4, 2026
fceeea4
Merge branch 'main' of https://github.com/codeabovelab/codeboost into…
mchwang Oct 4, 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. `POST /api/runner` with `start` or `resume` runs the plan; the review screen has no buttons for them yet. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. Demos never publish.
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. When a run has completed every item, codeboost pushes the task head to a `codeboost/…` branch with your `gh` credentials and opens a ready pull request into `baseBranch`; a task that needs a person gets a draft pull request with its problems. A publish that was refused (for example, the branch holds a commit codeboost did not make) is retried with the `publish` action, and `GET /api/runner` shows the last outcome under `publish`. When a task is cancelled, codeboost closes the open pull requests it opened for it (the branch is kept). A pull request a person moved is left open and reported, and `close-pull-requests` retries a close that failed. Demos never publish.

## Library

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ Dependencies point downward only. `core/` imports nothing that does input or out
| Merge coordinator | `runner/merge.ts` | Runs the full merge gate twice, re-reads the local generation, then merges the exact reviewed head. Tracks merge-queue attempts. | Yes |
| Runner coordinator | `runner/coordinator.ts`, `runner/lifecycle.ts` | Attempt admission, concurrency slots, compare-and-swap on results, retries, shutdown order. | Not connected |
| Execution | `runner/execution.ts` | Runs plan items in order: fresh workspace, prompt, agent, post-run audit, then the runner's own commit and ledger entry. Pauses in needs amendment on an out-of-scope edit; moves to needs human on a safety violation. | Not connected; uses a test workspace until #66 part 2 and #87 |
| Publishing | `runner/publish.ts`, `runner/publishing.ts`, `runner/branch-push.ts` | Runs the already-fixed check, pushes the task head, then opens or reuses the task's pull request (a draft when the task needs a person). Records each opening before calling GitHub, so a lost outcome can be found again by a marker. `TaskPublishing` (#103) publishes when a run ends, on the `publish` runner action, and once at startup, and records the last outcome. | With a runner block (needs `github.baseBranch`); never in a demo |
| Publishing | `runner/publish.ts`, `runner/publishing.ts`, `runner/branch-push.ts` | Runs the already-fixed check, pushes the task head, then opens or reuses the task's pull request (a draft when the task needs a person). Records each opening before calling GitHub, so a lost outcome can be found again by a marker. `TaskPublishing` (#103) publishes when a run ends, on the `publish` runner action, and once at startup, and records the last outcome. On cancel it closes the task's PRs (#111). | With a runner block (needs `github.baseBranch`); never in a demo |
| Lock and startup recovery | `runner/recovery.ts` | One runner per database, held as an OS lock on the database file's device and inode. Startup recovery finalizes interrupted attempts and removes leftover containers and storage. | Lock in use; recovery not connected |
| Store | `runner/store.ts` | The only database handle and all SQL. Revision numbers, snapshots, ledger, approvals, choices, notes, suggestion requests, tasks, attempts, user actions, feedback events, merge attempts, pull request openings, already-fixed results. | In use |
| Plan logic | `core/plan.ts`, `core/parse-v1.ts`, `schema/` | Schema validation, YAML and JSON import, meaning checks (IDs, dependencies, paths, projected file operations), literal command parsing, suggestion edits. | Yes |
Expand Down
25 changes: 5 additions & 20 deletions docs/implementation/pull-request-opening.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/implementation/runner-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ codeboost never writes the user's repository. The commits it makes live in a **r
|---|---|---|
| 1 | Set `stopping` on the server and make the runner coordinator reject admission, in the same synchronous turn, before any active-work list is copied. New API requests get HTTP 503, and new runner work throws `ShuttingDownError`. **Admitted requests are not rejected:** AGENTS.md says to drain already-admitted HTTP requests, and to recheck the flag only at irreversible boundaries. So after reading a body, the server rechecks `stopping` only for `merge`. The question coordinator also stops admission in this turn (`questions.stopAdmission()`): a question admitted before shutdown still saves its note, but starts no agent and gets a retryable "Server stopped" answer (`markStopped`). **Changed while implementing F1c:** an earlier revision of this row rejected every admitted write and closed the Store write gate here. That broke the drain rule and the existing browser regression "drains an in-flight question request before closing its agent manager", so the gate now closes in step 3. | server flag, merge recheck and question admission stop: yes. Runner barrier: new. |
| 2 | Stop accepting connections, and wait for admitted requests up to the drain limit (at most 14.5 s, below the 15 s request timeout). | yes |
| 2a | Abort every publish in progress (`TaskPublishing.close()`, #103) in the same turn as step 1, and await it here, at the end of the drain, before the write gate closes: the publisher already refuses every later push and opening through the coordinator's `closing` flag, and a publish's last records (an abandoned opening, the outcome) must still land. A publish stopped this way is owed, so the next start publishes it again. | new (#103) |
| 2a | Abort every publish and PR close in progress (`TaskPublishing.close()`, #103, #111) in the same turn as step 1, and await it here, at the end of the drain, before the write gate closes: the publisher already refuses every later push and opening through the coordinator's `closing` flag, and a publish's last records (an abandoned opening, the outcome) must still land. A publish stopped this way is owed, so the next start publishes it again. | new (#103) |
| 3 | After the drain limit, abort the signals of the remaining requests, destroy requests that are still reading a body, then **close the `Store` write gate**, then await request-owned work (the merge coordinator). From here on, every `Store` write throws `ShuttingDownError` unless the caller passes the shutdown capability. The server hands that capability at construction only to coordinators' settlement and close code (the runner, questions' `finishAnswer`, merge settlement after an irreversible command, and E3's `settleSuggestion` when it is wired), and to startup recovery; HTTP handlers and `ReviewService` never receive it. So a request-path write still pending after the abort (an aborted `/api/review` whose `load()` reaches `recordHistory`, or merge reconciliation and queue polling after a GitHub await) fails. The server maps `ShuttingDownError` to HTTP 503 "The review server is shutting down.", never to the 409 used for review errors, and there is no partial view. **Today the merge coordinator catches every error in `displayStatus()` and `#pollQueue()`**, which would swallow the gate error and answer 200; F1 makes each of those catch blocks rethrow `ShuttingDownError` first. The next startup checks GitHub again, so a skipped merge observation is recovered. The server also closes idle keep-alive connections once shutdown starts and after each request finishes, so a finished request's socket cannot hold `server.close()` open until its keep-alive timeout. | abort: yes. Gate, capability, rethrow and idle-connection close: new. |
| 4 | For every `pending` and `running` attempt that has an in-memory job, record the first reason `shutdown` **only if no first reason is set yet**. A job already marked `cancelled`, `stale` or `time-limit` keeps its reason. For `running`, call `cancel('shutdown')` and await `settled`. For `pending`, await its preparation promise (the D start call itself returns at once, so it is never in progress), remove only its host-side preparation files (task storage waits for the terminal write, as in step 5), and cancel any handle that start returned, then await its `settled` (the "Launch" table). F does not abandon a job after a timer (decision 4). **This ends:** D's supervisor escalates to a forced kill, and retries unfinished container, network or setup cleanup every second for at most 60 seconds after the first failure (`CLEANUP_RETRY_WINDOW_MS`, `agents/adapters/supervisor.ts`). Retries after the first get only what is left of the window, and every cleanup subprocess is killed at its deadline, so settlement ends about 90 seconds after cleanup starts at worst (up to 30 seconds for the first, failed attempt, then the 60-second window). Then `settled` resolves with a `stopReason` and an `unreleased` list, even if Docker is unreachable or hangs. F records `unreleased` durably before it frees the slot. See the prerequisite below. | new |
| 5 | For each attempt from step 4, in the order set by the task-storage holder row: the partial-output export (writable attempts), then the terminal state from its first reason (a `shutdown` reason gives `cancelled` "Stopped by shutdown") with its `diagnostic_ref`, then `removeTaskFilesystems`, and only then free its slot. A failed write leaves the row non-terminal, and a failed removal keeps the slot held. Startup recovery handles both. | new |
Expand Down
34 changes: 34 additions & 0 deletions github/pull-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export interface PullRequestGateway {
/** Replaces the title and description of an open PR codeboost opened; marks it ready when `ready`, or a draft when `draft`. */
/** `beforeReady` runs after the description update's await and before any ready or draft change; if it throws, no such change is made. */
refresh(number: number, input: OpenPullRequestInput & { ready: boolean; headSha?: string; beforeReady?: () => void }, signal?: AbortSignal): Promise<OpenedPullRequest>;
/**
* Closes a PR codeboost opened (#111), read by its number (GitHub's list may lag behind it). An open PR must still be from
* `headBranch` in this repository with `marker` on its first line, or it is refused (`PullRequestMisplaced`) and left
* open. Already closed is success. `beforeClose` runs after the read and right before the close; if it throws, nothing
* is closed. The branch is kept. Settles only once GitHub shows the PR closed.
*/
close(number: number, input: { headBranch: string; marker: string; beforeClose?: () => void }, signal?: AbortSignal): Promise<{ number: number; url: string }>;
/** Turns an open PR codeboost opened back into a draft; a no-op for a draft. */
markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise<OpenedPullRequest>;
}
Expand Down Expand Up @@ -259,6 +266,33 @@ export class GhPullRequestGateway implements PullRequestGateway {
return pr;
}

async close(number: number, input: { headBranch: string; marker: string; beforeClose?: () => void }, signal?: AbortSignal): Promise<{ number: number; url: string }> {
signal = this.#bounded(signal);
this.#validate(input);
if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid pull request number.');
const read = (value: unknown) => {
const pr = value as { number?: unknown; html_url?: unknown; state?: unknown; body?: unknown; head?: { ref?: unknown; repo?: { full_name?: unknown } | null } } | null;
if (!pr || pr.number !== number || typeof pr.html_url !== 'string' || !pr.html_url.startsWith('https://') || (pr.state !== 'open' && pr.state !== 'closed')
|| (pr.body !== null && typeof pr.body !== 'string') || typeof pr.head?.ref !== 'string') throw new Error('GitHub returned an invalid pull request.');
const repo = pr.head.repo?.full_name;
const ours = pr.head.ref === input.headBranch && typeof repo === 'string' && repo.toLowerCase() === this.repository.toLowerCase() && markerOf(pr.body ?? '') === input.marker;
return { number, url: pr.html_url, open: pr.state === 'open', ours };
};
const url = `repos/${this.repository}/pulls/${number}`;
const before = read(await this.#json(['api', '-H', 'Accept: application/vnd.github+json', url], signal));
// Closed already, by anyone: nothing is left to do, whatever its description says now.
if (!before.open) return { number, url: before.url };
// Never someone else's PR: an open one must still be from the task branch in this repository, with the task's marker.
if (!before.ours) throw new PullRequestMisplaced(`Pull request #${number} is open but no longer from ${input.headBranch} with its first line, so codeboost did not close it. Close it, or restore its branch and first line.`);
// The caller's re-check (shutdown, task state) right before the irreversible change, with no await since.
input.beforeClose?.();
const after = read(await this.#json(['api', '-X', 'PATCH', '-H', 'Accept: application/vnd.github+json', url], signal, { state: 'closed' }));
// A command that exits 0 does not prove the change applied: the answer must show the PR closed. Only its state is
// checked now: the close has happened, so a description edited meanwhile must not turn it into a failure.
if (after.open) throw new Error(`GitHub did not close pull request #${number}.`);
return { number, url: after.url };
}

/** The caller has just read the PR as ready, so this changes it straight away and reads the result back once. */
async markDraft(number: number, input: { base: string; headBranch: string; marker: string }, signal?: AbortSignal): Promise<OpenedPullRequest> {
signal = this.#bounded(signal);
Expand Down
Loading
Loading