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
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`. 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. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. 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 runner buttons yet. A task paused for an out-of-scope change stays paused until the plan is amended, every changed path is declared, the remaining items validate against the runner's audited tree, and a person sends `approve-continuation` with the current `expectedStateVersion`, `expectedReviewVersion`, and an action ID. Then `resume` starts at the first unfinished item. `GET /api/runner` reports the checkpoint and whether the continuation is approved. 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. Merge PR then merges that pull request, so `github.pullRequest` can be left out; if it is set, it must name the task's pull request, or merging is blocked. Demos never publish.

## Library

Expand Down
47 changes: 42 additions & 5 deletions core/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface PlanContext {
allowedCommands: readonly (readonly string[])[];
issue: number;
}
export interface ContinuationBinding { checkpointId: string; head: string; completedItems: readonly string[] }
export interface Validation { errors: Diagnostic[]; warnings: Diagnostic[] }
export class PlanError extends Error {
readonly diagnostics: Diagnostic[];
Expand Down Expand Up @@ -137,6 +138,39 @@ export function validatePlan(value: unknown, context: PlanContext): Validation {
return validator(value, context);
}

/** Validate only work still to run, starting with the runner-audited tree at its checkpoint. */
export function validateContinuationPlan(plan: Plan, context: PlanContext, completedItems: readonly string[], allowEmptySuffix = false): Validation {
try { assertPlan(plan); } catch (error) {
if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] };
throw error;
}
const prefix = plan.items.slice(0, completedItems.length).map(item => item.id);
Comment thread
Copilot marked this conversation as resolved.
if (!completedItems.length || prefix.length !== completedItems.length || prefix.some((id, index) => id !== completedItems[index]) ||
completedItems.length > plan.items.length || (!allowEmptySuffix && completedItems.length === plan.items.length))
return { errors: [{ code: 'continuation-prefix', message: 'The amended plan must retain the completed item prefix and have a remaining item.' }], warnings: [] };
const ids = new Set<string>();
for (const item of plan.items) {
if (ids.has(item.id)) return { errors: [{ code: 'duplicate-id', message: `Duplicate item ID ${item.id}.`, item: item.id }], warnings: [] };
if (new Set(item.depends_on).size !== item.depends_on.length)
return { errors: [{ code: 'dependency', message: 'Dependencies must be unique.', item: item.id }], warnings: [] };
ids.add(item.id);
}
// There is no executable suffix to validate, but the plan remains bound to
// the selected issue even after every item has completed.
if (completedItems.length === plan.items.length) {
if (!Number.isSafeInteger(context.issue) || context.issue < 1)
return { errors: [{ code: 'context', message: 'A selected issue is required.' }], warnings: [] };
if (plan.issue !== context.issue)
return { errors: [{ code: 'issue', message: 'Plan issue does not match the selected issue.' }], warnings: [] };
return { errors: [], warnings: [] };
}
const completed = new Set(completedItems);
const suffix: Plan = { ...plan, items: plan.items.slice(completedItems.length).map(item => ({
...item, depends_on: item.depends_on.filter(id => !completed.has(id)),
})) };
return validatePlan(suffix, context);
}

function validateV1(value: Plan, context: PlanContext): Validation {
try { assertPlan(value); } catch (error) {
if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] };
Expand Down Expand Up @@ -273,15 +307,17 @@ function validateV1(value: Plan, context: PlanContext): Validation {
return { errors, warnings };
}

export function importPlan(source: string | Uint8Array, format: 'json' | 'yaml', context: PlanContext, revision: number): { plan: Plan; warnings: Diagnostic[] } {
export function importPlan(source: string | Uint8Array, format: 'json' | 'yaml', context: PlanContext, revision: number,
completedItems?: readonly string[]): { plan: Plan; warnings: Diagnostic[] } {
if (!Number.isSafeInteger(revision) || revision < 1) fail('revision', 'Revision must be a positive safe integer.');
let data: unknown;
try { data = parseV1(source, format); }
catch (error) { fail('parse', `Cannot parse plan: ${(error as Error).message}`); }
assertPlan(data); // No migrations exist yet: only released v1 is accepted.
const result = validatePlan(data, context);
const plan = { ...data, revision };
const result = completedItems === undefined ? validatePlan(data, context) : validateContinuationPlan(plan, context, completedItems, true);
if (result.errors.length) throw new PlanError(result.errors);
return { plan: { ...data, revision }, warnings: result.warnings };
return { plan, warnings: result.warnings };
}

const payloads = ['field', 'value', 'file', 'check', 'check_index', 'depends_on', 'new_item'] as const;
Expand All @@ -297,7 +333,8 @@ export interface SuggestionBinding {
/** Pure transformation. The store must load this binding by opaque suggestion ID,
* verify cancellation/consumption, and CAS revision plus consume/invalidate IDs atomically.
* This function cannot provide persistence, replay prevention, or concurrency control. */
export function applySuggestion(plan: Plan, reply: unknown, index: number, context: PlanContext, binding: SuggestionBinding): Plan {
export function applySuggestion(plan: Plan, reply: unknown, index: number, context: PlanContext, binding: SuggestionBinding,
completedItems?: readonly string[]): Plan {
assertPlan(plan); assertEditReply(reply);
if (!binding || identityKey(binding.identity) !== identityKey(context.identity) ||
binding.schemaVersion !== plan.schema_version || binding.baseRevision !== reply.base_revision ||
Expand Down Expand Up @@ -339,7 +376,7 @@ export function applySuggestion(plan: Plan, reply: unknown, index: number, conte
case 'set_depends': item.depends_on = edit.depends_on!; break;
}
}
const result = validatePlan(next, context);
const result = completedItems === undefined ? validatePlan(next, context) : validateContinuationPlan(next, context, completedItems, true);
if (result.errors.length) throw new PlanError(result.errors);
if (!Number.isSafeInteger(next.revision + 1)) fail('revision', 'Revision limit reached.');
next.revision++;
Expand Down
32 changes: 24 additions & 8 deletions core/planning-author.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { identityKey, type PlanIdentity } from './identity.ts';
import { parseV1 } from './parse-v1.ts';
import { applySuggestion, assertEditReply, importPlan, validatePlan, PlanError,
import { applySuggestion, assertEditReply, importPlan, validateContinuationPlan, validatePlan, PlanError,
type Diagnostic, type EditReply, type Plan, type PlanContext } from './plan.ts';
import registry from '../schema/versions.json' with { type: 'json' };

Expand All @@ -19,6 +19,8 @@ export interface AuthorInput {
approvedLessons: readonly string[];
feedback: string;
previousPlan?: Plan;
completedItems?: readonly string[];
continuationContext?: { checkpointId: string; head: string; completedItems: readonly string[]; ownerItem: string; outOfScopePaths: readonly string[] } | null;
}
export interface AuthorRequest {
readonly mode: 'draft' | 'suggest';
Expand All @@ -39,7 +41,7 @@ export interface AuthorProvider {
}
export interface PreparedAuthor<T> {
readonly request: AuthorRequest;
validate(source: string | Uint8Array): { value: T; warnings: Diagnostic[] };
validate(source: string | Uint8Array): { value: T; warnings: Diagnostic[]; candidatePlans: Plan[] };
}

function integer(value: number, label: string): void {
Expand Down Expand Up @@ -93,8 +95,17 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
const context: PlanContext = { ...input.context, identity: { repositoryId, taskId, planId },
baseEntries: structuredClone(input.context.baseEntries), allowedCommands: structuredClone(input.context.allowedCommands) };
const previous = input.previousPlan ? structuredClone(input.previousPlan) : undefined;
const completedItems = input.completedItems === undefined || input.completedItems.length === 0
? undefined : structuredClone([...input.completedItems]);
Comment thread
mchwang marked this conversation as resolved.
if (completedItems && (!input.continuationContext || input.continuationContext.completedItems.length !== completedItems.length ||
input.continuationContext.completedItems.some((item, index) => item !== completedItems[index])))
throw new Error('Planning continuation context does not match its completed prefix.');
if (previous) {
const result = validatePlan(previous, context);
// The existing suffix is context for the provider to repair, not a candidate. Validate its structure and the
// checkpoint-owned prefix here; require the proposed candidate to pass full suffix semantics below.
const result = completedItems === undefined ? validatePlan(previous, context) : validateContinuationPlan({
...previous, items: previous.items.slice(0, completedItems.length),
}, context, completedItems, true);
if (result.errors.length) throw new PlanError(result.errors);
if (input.revision !== previous.revision + (mode === 'draft' ? 1 : 0)) throw new Error('Previous plan revision mismatch.');
} else if (mode === 'suggest') throw new Error('Suggestions require a previous plan.');
Expand All @@ -115,6 +126,9 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
lessons_data_json: dataJSON(input.approvedLessons, 'Lessons'),
feedback_data_json: dataJSON(input.feedback, 'Feedback'),
previous_plan_json: dataJSON(previous ?? null, 'Previous plan'),
continuation_context_instruction: input.continuationContext
? `Checkpoint continuation context:\n<continuation_context>\n${dataJSON(input.continuationContext, 'Continuation context')}\n</continuation_context>\nWhen this context is present, preserve the completed item IDs. Only the checkpoint owner may be amended to declare the observed paths. Do not rewrite completed work or claim unfinished work already ran. The unfinished suffix is context for correction and may currently fail validation against the audited tree; the candidate you return must correct it so the entire remaining suffix is valid against that tree.\n`
: '',
};
const conditional = template.replace(/\{\{#if previous_plan\}\}([\s\S]*?)\{\{\/if\}\}/gu, (_, block: string) => previous ? block : '');
// One pass over trusted template only: inserted data is never interpreted again.
Expand All @@ -130,18 +144,20 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
// importPlan replaces a revision for user imports; provider output must match it first.
const data = parseV1(source, 'json');
if ((data as Plan | null)?.revision !== request.revision) throw new Error('Response revision mismatch.');
const result = importPlan(source, 'json', context, request.revision);
return { value: result.plan, warnings: result.warnings };
const result = importPlan(source, 'json', context, request.revision, completedItems);
return { value: result.plan, warnings: result.warnings, candidatePlans: [result.plan] };
}
const reply = parseV1(source, 'json'); assertEditReply(reply);
if (reply.base_revision !== request.revision) throw new Error('Response revision mismatch.');
const warnings: Diagnostic[] = [];
const candidatePlans: Plan[] = [];
// Validate every independent card against the captured plan before exposing any card.
for (let index = 0; index < reply.edits.length; index++) {
const next = applySuggestion(previous!, reply, index, context, { identity: context.identity,
schemaVersion: previous!.schema_version, baseRevision: request.revision, issue: context.issue });
warnings.push(...validatePlan(next, context).warnings);
schemaVersion: previous!.schema_version, baseRevision: request.revision, issue: context.issue }, completedItems);
candidatePlans.push(next);
warnings.push(...(completedItems === undefined ? validatePlan(next, context) : validateContinuationPlan(next, context, completedItems, true)).warnings);
}
return { value: reply, warnings };
return { value: reply, warnings, candidatePlans };
} });
}
20 changes: 13 additions & 7 deletions core/planning-suggestions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { identityKey, type PlanIdentity } from './identity.ts';
import { prepareDraft, prepareSuggestions, type AuthorInput, type AuthorProvider, type PreparedAuthor } from './planning-author.ts';
import type { Diagnostic, EditReply, Plan } from './plan.ts';
import type { ContinuationBinding, Diagnostic, EditReply, Plan } from './plan.ts';

/** Implemented by the existing Store. No SQL or second persistence writer in core. */
export interface SuggestionStore {
getPlan(identity: PlanIdentity): Plan;
getSnapshot(identity: PlanIdentity): { id: string; base: string; head: string };
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }, mode: PlanningMode): string;
completeSuggestions(identity: PlanIdentity, id: string, reply: unknown): void;
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }, mode: PlanningMode, continuation?: ContinuationBinding | null): string;
completeSuggestions(identity: PlanIdentity, id: string, reply: unknown, candidatePlans?: Plan[]): boolean | void;
settleSuggestion(identity: PlanIdentity, id: string, expected: { revision: number; snapshotId: string }, outcome: { state: 'failed' | 'cancelled' | 'invalidated'; reason: string }): boolean;
getSuggestions(identity: PlanIdentity, id: string): { state: string; revision: number; snapshotId: string | null; reply: EditReply | null; reason: string | null };
}
Expand All @@ -16,7 +16,7 @@ export interface SuggestionStore {
* in the input is always the current revision the request is bound to.
*/
export type PlanningMode = 'suggest' | 'draft';
export type SuggestionInput = Omit<AuthorInput, 'requestId' | 'previousPlan'> & { snapshotId: string };
export type SuggestionInput = Omit<AuthorInput, 'requestId' | 'previousPlan'> & { snapshotId: string; continuationBinding?: ContinuationBinding | null };
export type SuggestionOutcome =
| { state: 'completed'; id: string; warnings: Diagnostic[] }
| { state: 'failed' | 'cancelled' | 'stale'; id: string; reason: string };
Expand Down Expand Up @@ -57,6 +57,11 @@ export class SuggestionCoordinator {
if (this.#closing) throw new Error('Planning coordinator is closing.');
const identity = { ...input.context.identity }, key = identityKey(identity);
if (this.#active.has(key)) throw new Error('A planning invocation is still active for this plan.');
const completed = input.completedItems ?? [];
if ((input.continuationBinding === null || input.continuationBinding === undefined) !== (completed.length === 0) ||
(input.continuationBinding && (input.continuationBinding.completedItems.length !== completed.length ||
input.continuationBinding.completedItems.some((item, index) => item !== completed[index]))))
throw new Error('Planning continuation binding does not match its completed prefix.');
const snapshotId = input.snapshotId;
const previousPlan = this.#store.getPlan(identity), snapshot = this.#store.getSnapshot(identity);
if (previousPlan.revision !== input.revision) throw new Error('Stale plan revision.');
Expand All @@ -67,7 +72,7 @@ export class SuggestionCoordinator {
: prepareSuggestions({ ...input, requestId: 'pending', previousPlan });
const expected = Object.freeze({ revision: input.revision, snapshotId });
const noun = mode === 'draft' ? 'Draft' : 'Suggestion';
const id = this.#store.beginSuggestions(identity, expected, mode);
const id = this.#store.beginSuggestions(identity, expected, mode, input.continuationBinding ?? null);
const request = Object.freeze({ ...prepared.request, requestId: id });
const controller = new AbortController();
let stopped: Omit<TerminalOutcome, 'id'> | undefined;
Expand Down Expand Up @@ -119,8 +124,9 @@ export class SuggestionCoordinator {
outcome = { id, state: current.state === 'cancelled' ? 'cancelled' : 'stale', reason: `${noun} request is ${current.state}.` };
} else {
const validated = prepared.validate(source);
this.#store.completeSuggestions(identity, id, validated.value);
outcome = { id, state: 'completed', warnings: validated.warnings };
const saved = this.#store.completeSuggestions(identity, id, validated.value, validated.candidatePlans);
outcome = saved === false ? { id, state: 'stale', reason: 'Checkpoint continuation changed during authoring.' }
: { id, state: 'completed', warnings: validated.warnings };
}
}
}
Expand Down
Loading
Loading