Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ An existing-store configuration may add a trusted GitHub binding:

The issue must match the stored plan. The authenticated `gh` account must be able to read the pull request, issue timeline, applicable rulesets, and classic branch protection, and to merge the PR. Codeboost unions required checks from both rule sources, requires strict server-enforced current-base checks, rechecks the base and head immediately before merging, and passes the reviewed head to `gh pr merge --match-head-commit`. Missing permissions and ambiguous rule responses block the merge. When the branch uses a merge queue, codeboost adds the exact reviewed head to the queue and treats the merge as done only when GitHub confirms it merged; a queued pull request is not merged. If GitHub removes it from the queue while the plan, snapshot, review and head are unchanged, Merge offers a retry after the status refresh; if the head changed, it goes back to review first. A moved base and any unexecuted `cmd:` acceptance check remain blocked until [#22](https://github.com/codeabovelab/codeboost/issues/22) adds the runner path.

## Runner (opt-in)

A configuration with a `github` block may also add a `runner` block. The runner carries out plan items with Claude Code in the agent container and commits each item in its own repository, never in yours:

```json
{
"runner": {
"root": "/absolute/path/owned/by/you",
"committer": { "name": "codeboost", "email": "runner@example.invalid" }
}
}
```

`root` must be a directory only you can write. Optional fields:
- `diagnosticsDir`: where partial output of stopped attempts is kept, in a folder per database (`<diagnosticsDir>/<runner token>/diagnostics`), so databases sharing it never delete each other's files. The default is the runner root.
- `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).

## Library

- `core/plan.ts`: schema validation; YAML/JSON import; projected file-state and dependency checks; literal command parsing; individual suggestion validation and application.
Expand Down
7 changes: 5 additions & 2 deletions docs/implementation/runner-lifecycle.md

Large diffs are not rendered by default.

76 changes: 71 additions & 5 deletions github/issues.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ghEnvironment } from './gh-env.ts';
import { dataJSON, MAX_PROMPT_BYTES } from '../core/planning-author.ts';
import { runWithInput } from './run-with-input.ts';

const PAGE_SIZE = 100;
Expand Down Expand Up @@ -41,6 +42,9 @@ export interface IssueSnapshot {
readonly issues: readonly RepositoryIssue[];
}

/** The issue text an execute prompt carries, as untrusted data. */
export interface IssueText { readonly number: number; readonly title: string; readonly body: string; readonly comments: readonly string[] }

export interface IssueGateway {
readonly repository: string;
fetch(options?: { signal?: AbortSignal; timeoutMs?: number }): Promise<IssueSnapshot>;
Expand Down Expand Up @@ -229,7 +233,61 @@ export class GhIssueGateway implements IssueGateway {
}));
}

async fetch(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueSnapshot> {
/**
* One issue's text for an execute prompt (#91): its title, body and the comments of repository collaborators only
* (design, "Which comments reach the agent"), oldest first. Everything stays untrusted data inside the prompt.
* Fails closed on anything malformed, on a pull request, and past the comment page limit.
*/
async issueText(number: number, options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueText> {
if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid issue number.');
return this.#bounded(options, async signal => {
let decoded: unknown;
const output = await this.run(['api', '--method', 'GET', '-H', 'Accept: application/vnd.github+json', `repos/${this.repository}/issues/${number}`], { signal });
try { decoded = JSON.parse(output); }
catch { throw new Error('GitHub returned invalid issue JSON.'); }
const issue = object(decoded, 'GitHub returned a malformed issue.');
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.
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.`);
let total = Buffer.byteLength(title) + Buffer.byteLength(body);
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 });
let values: unknown;
try { values = JSON.parse(listed); }
catch { throw new Error('GitHub returned invalid comment JSON.'); }
if (!Array.isArray(values) || values.length > PAGE_SIZE) throw new Error('GitHub returned an invalid comment page.');
// Only an empty page past the limit ends the read cleanly; anything on it is more than codeboost reads.
if (page > MAX_PAGES && values.length) throw new Error(`Issue #${number} has more than ${MAX_ISSUES} comments; codeboost does not read past that limit.`);
for (const value of values) {
const comment = object(value, 'GitHub returned a malformed comment.');
// A deleted ("ghost") author is nobody's collaborator. Other people's comments are dropped before their body is
// checked, so none of theirs can make the issue unreadable.
if (comment.user === null) continue;
const author = login(object(comment.user, 'GitHub returned an invalid comment author.').login, 'issue author');
if (!collaborators.has(author.toLocaleLowerCase('en-US'))) continue;
const text = boundedString(comment.body, 'comment', MAX_BODY_LENGTH, true);
total += Buffer.byteLength(text);
if (total > MAX_PROMPT_BYTES) throw tooLong();
comments.push(text);
}
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; }
return text;
});
}

/** Runs `load` under the caller's signal and an overall deadline, as `fetch` does. */
async #bounded<T>(options: { signal?: AbortSignal; timeoutMs?: number }, load: (signal: AbortSignal) => Promise<T>): Promise<T> {
const timeoutMs = options.timeoutMs ?? 12_000;
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 30_000) throw new Error('Invalid issue retrieval timeout.');
options.signal?.throwIfAborted();
Expand All @@ -238,11 +296,9 @@ export class GhIssueGateway implements IssueGateway {
options.signal?.addEventListener('abort', relay, { once: true });
const timer = setTimeout(() => controller.abort(new Error('Issue retrieval timed out.')), timeoutMs);
try {
const issues = await this.#load(controller.signal);
const value = await load(controller.signal);
controller.signal.throwIfAborted();
const retrievedAt = this.now();
if (!Number.isFinite(retrievedAt.getTime())) throw new Error('Issue retrieval clock is invalid.');
return { repository: this.repository, retrievedAt: retrievedAt.toISOString(), issues };
return value;
} catch (error) {
if (options.signal?.aborted) throw options.signal.reason;
if (controller.signal.aborted) throw new Error('Issue retrieval timed out.');
Expand All @@ -252,4 +308,14 @@ export class GhIssueGateway implements IssueGateway {
options.signal?.removeEventListener('abort', relay);
}
}

async fetch(options: { signal?: AbortSignal; timeoutMs?: number } = {}): Promise<IssueSnapshot> {
return this.#bounded(options, async signal => {
const issues = await this.#load(signal);
signal.throwIfAborted();
const retrievedAt = this.now();
if (!Number.isFinite(retrievedAt.getTime())) throw new Error('Issue retrieval clock is invalid.');
return { repository: this.repository, retrievedAt: retrievedAt.toISOString(), issues };
});
}
}
8 changes: 8 additions & 0 deletions runner/coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ export interface RunnerDeps {
* under the same token, because D refuses storage owned by another runner.
*/
readonly runnerOwner: string;
/**
* The attempt kinds these deps can run. Admission refuses any other kind before it writes anything, so a retry of,
* say, a review attempt never reaches deps that would run it as an execute. Omitted: every kind.
*/
readonly kinds?: readonly AttemptKind[];
/**
* Host-side preparation (clone, prompt). On abort it must stop and await every subprocess it started, then reject.
* It never leaves work running after it settles.
Expand Down Expand Up @@ -135,6 +140,8 @@ export class RunnerCoordinator {
get closing(): boolean { return this.#closing; }
/** Resources D could not confirm removed; non-null keeps the runner closed to new work until restart. */
get unreleased(): readonly UnreleasedResource[] | null { return this.#unreleased; }
/** Whether admission would take this kind (`RunnerDeps.kinds`); a view must not offer what admission refuses. */
runs(kind: AttemptKind): boolean { return !this.#deps.kinds || this.#deps.kinds.includes(kind); }
#now(): number { return this.#deps.now?.() ?? Date.now(); }
#used(group: Group): number {
let used = 0;
Expand All @@ -154,6 +161,7 @@ export class RunnerCoordinator {
const marker = this.#markers.get(key);
if (marker) throw new GuardRefusal(NEEDS_RESTART[marker.reason]);
if (!(request.kind in ATTEMPT_PHASES)) throw new GuardRefusal('Unknown attempt kind.');
if (this.#deps.kinds && !this.#deps.kinds.includes(request.kind)) throw new GuardRefusal(`The runner cannot run ${request.kind} attempts yet.`);
const group: Group = WRITABLE_KINDS.includes(request.kind) ? 'writable' : 'readOnly';
if (this.#used(group) >= this.#limits[group]) throw new GuardRefusal('No free runner slot. Try again when the current attempt finishes.');
const job: Job = { identity: { ...identity }, key, group, attemptId: '', firstReason: null, reasonSaved: true, preparationTimedOut: false, controller: new AbortController(), timers: [] };
Expand Down
18 changes: 13 additions & 5 deletions runner/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import type { Store } from './store.ts';
export const DEFAULT_DIAGNOSTICS_CAP_BYTES = 256 * 1024 * 1024;
const NAME = /^([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})\.diff$/;

/**
* D's export bytes as saved: a diff D cut at its byte limit ends with a `codeboost:` notice line (like D's own notices),
* so a reader never takes a cut diff for everything the agent changed.
*/
export function partialOutput(diff: Buffer, truncated: boolean): Buffer {
return truncated ? Buffer.concat([diff, Buffer.from(`\ncodeboost: the partial output was cut at ${diff.length} bytes; later changes are not shown.\n`)]) : diff;
}
/**
* Save a stopped attempt's partial output as `<directory>/<attemptId>.diff` (runner-lifecycle.md, the task storage
* row), then apply retention. The file is written through a unique temporary file opened exclusively, owner-only, and
* renamed into place; a failed write leaves nothing. Returns the path the attempt row references as `diagnostic_ref`.
*/
export function saveDiagnostic(store: Store, directory: string, attemptId: string, bytes: Buffer,
capBytes = DEFAULT_DIAGNOSTICS_CAP_BYTES): string {
capBytes = DEFAULT_DIAGNOSTICS_CAP_BYTES, alsoKeep: readonly string[] = []): string {
if (!isUuidV4(attemptId)) throw new Error('Attempt ID must be a UUID v4.');
if (!Number.isSafeInteger(capBytes) || capBytes < 1) throw new Error('The diagnostics cap must be a positive integer.');
const path = join(directory, `${attemptId}.diff`), temporary = join(directory, `.${attemptId}.${randomUUID()}.tmp`);
Expand All @@ -23,7 +30,7 @@ export function saveDiagnostic(store: Store, directory: string, attemptId: strin
renameSync(temporary, path);
} catch (error) { rmSync(temporary, { force: true }); throw error; }
// The saved file stands even if retention cannot run now (the write gate closed): the next save retains again.
try { retain(store, directory, capBytes, path); }
try { retain(store, directory, capBytes, [path, ...alsoKeep]); }
catch (error) { console.error(`Diagnostics retention did not run: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); }
return path;
}
Expand All @@ -32,9 +39,10 @@ export function saveDiagnostic(store: Store, directory: string, attemptId: strin
* Retention never deletes a file an attempt row still references. Over the cap, it deletes unreferenced files oldest
* first; if that is not enough, it takes the oldest referenced file and, in one transaction, clears that row's
* `diagnostic_ref` and notes the removal in its diagnostic, and only after that commit deletes the file. The file just
* saved (`keep`) is never removed: it is not referenced until the terminal write.
* saved (`keep`) is never removed: it is not referenced until the terminal write. Startup recovery saves several before
* its one finalization transaction references them, so it keeps every one it saved.
*/
export function retain(store: Store, directory: string, capBytes: number, keep?: string): void {
export function retain(store: Store, directory: string, capBytes: number, keep: readonly string[] = []): void {
const files = readdirSync(directory).flatMap(name => {
const match = NAME.exec(name), path = join(directory, name);
if (!match) return [];
Expand All @@ -46,7 +54,7 @@ export function retain(store: Store, directory: string, capBytes: number, keep?:
for (const unreferenced of [true, false]) {
for (const file of files) {
if (total <= capBytes) return;
if (file.path === keep || referenced.has(file.path) === unreferenced) continue;
if (keep.includes(file.path) || referenced.has(file.path) === unreferenced) continue;
if (!unreferenced && !store.forgetDiagnostic(file.path)) continue;
rmSync(file.path, { force: true });
total -= file.size;
Expand Down
Loading
Loading