Skip to content

Commit 91e49c1

Browse files
authored
Resume tasks after scope amendments (#134)
* feat: resume tasks after scope amendments * Fix continuation approval and publishing gates * Exercise committed continuation approval ancestry * fix continuation checkpoint reconciliation and publishing * Validate continuation amendments against checkpoint * Fix continuation planning review findings * Fix checkpoint continuation review findings * Scope continuation context to checkpoint requests * Publish after empty continuation approval * Validate continuation amendment bounds * Reject duplicate continuation dependencies * Preserve continuation retry during shutdown * Keep continuation shutdown refusals retryable * Validate checkpoint declaration paths
1 parent 284d92e commit 91e49c1

29 files changed

Lines changed: 1424 additions & 155 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ A configuration with a `github` block may also add a `runner` block. The runner
5151
- `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.
5252
- `limits`: task storage limits (`workBytes`, `workInodes`, `metadataBytes`, `metadataInodes`).
5353

54-
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.
54+
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.
5555

5656
## Library
5757

‎core/plan.ts‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface PlanContext {
4242
allowedCommands: readonly (readonly string[])[];
4343
issue: number;
4444
}
45+
export interface ContinuationBinding { checkpointId: string; head: string; completedItems: readonly string[] }
4546
export interface Validation { errors: Diagnostic[]; warnings: Diagnostic[] }
4647
export class PlanError extends Error {
4748
readonly diagnostics: Diagnostic[];
@@ -137,6 +138,39 @@ export function validatePlan(value: unknown, context: PlanContext): Validation {
137138
return validator(value, context);
138139
}
139140

141+
/** Validate only work still to run, starting with the runner-audited tree at its checkpoint. */
142+
export function validateContinuationPlan(plan: Plan, context: PlanContext, completedItems: readonly string[], allowEmptySuffix = false): Validation {
143+
try { assertPlan(plan); } catch (error) {
144+
if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] };
145+
throw error;
146+
}
147+
const prefix = plan.items.slice(0, completedItems.length).map(item => item.id);
148+
if (!completedItems.length || prefix.length !== completedItems.length || prefix.some((id, index) => id !== completedItems[index]) ||
149+
completedItems.length > plan.items.length || (!allowEmptySuffix && completedItems.length === plan.items.length))
150+
return { errors: [{ code: 'continuation-prefix', message: 'The amended plan must retain the completed item prefix and have a remaining item.' }], warnings: [] };
151+
const ids = new Set<string>();
152+
for (const item of plan.items) {
153+
if (ids.has(item.id)) return { errors: [{ code: 'duplicate-id', message: `Duplicate item ID ${item.id}.`, item: item.id }], warnings: [] };
154+
if (new Set(item.depends_on).size !== item.depends_on.length)
155+
return { errors: [{ code: 'dependency', message: 'Dependencies must be unique.', item: item.id }], warnings: [] };
156+
ids.add(item.id);
157+
}
158+
// There is no executable suffix to validate, but the plan remains bound to
159+
// the selected issue even after every item has completed.
160+
if (completedItems.length === plan.items.length) {
161+
if (!Number.isSafeInteger(context.issue) || context.issue < 1)
162+
return { errors: [{ code: 'context', message: 'A selected issue is required.' }], warnings: [] };
163+
if (plan.issue !== context.issue)
164+
return { errors: [{ code: 'issue', message: 'Plan issue does not match the selected issue.' }], warnings: [] };
165+
return { errors: [], warnings: [] };
166+
}
167+
const completed = new Set(completedItems);
168+
const suffix: Plan = { ...plan, items: plan.items.slice(completedItems.length).map(item => ({
169+
...item, depends_on: item.depends_on.filter(id => !completed.has(id)),
170+
})) };
171+
return validatePlan(suffix, context);
172+
}
173+
140174
function validateV1(value: Plan, context: PlanContext): Validation {
141175
try { assertPlan(value); } catch (error) {
142176
if (error instanceof PlanError) return { errors: error.diagnostics, warnings: [] };
@@ -273,15 +307,17 @@ function validateV1(value: Plan, context: PlanContext): Validation {
273307
return { errors, warnings };
274308
}
275309

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

287323
const payloads = ['field', 'value', 'file', 'check', 'check_index', 'depends_on', 'new_item'] as const;
@@ -297,7 +333,8 @@ export interface SuggestionBinding {
297333
/** Pure transformation. The store must load this binding by opaque suggestion ID,
298334
* verify cancellation/consumption, and CAS revision plus consume/invalidate IDs atomically.
299335
* This function cannot provide persistence, replay prevention, or concurrency control. */
300-
export function applySuggestion(plan: Plan, reply: unknown, index: number, context: PlanContext, binding: SuggestionBinding): Plan {
336+
export function applySuggestion(plan: Plan, reply: unknown, index: number, context: PlanContext, binding: SuggestionBinding,
337+
completedItems?: readonly string[]): Plan {
301338
assertPlan(plan); assertEditReply(reply);
302339
if (!binding || identityKey(binding.identity) !== identityKey(context.identity) ||
303340
binding.schemaVersion !== plan.schema_version || binding.baseRevision !== reply.base_revision ||
@@ -339,7 +376,7 @@ export function applySuggestion(plan: Plan, reply: unknown, index: number, conte
339376
case 'set_depends': item.depends_on = edit.depends_on!; break;
340377
}
341378
}
342-
const result = validatePlan(next, context);
379+
const result = completedItems === undefined ? validatePlan(next, context) : validateContinuationPlan(next, context, completedItems, true);
343380
if (result.errors.length) throw new PlanError(result.errors);
344381
if (!Number.isSafeInteger(next.revision + 1)) fail('revision', 'Revision limit reached.');
345382
next.revision++;

‎core/planning-author.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFileSync } from 'node:fs';
22
import { identityKey, type PlanIdentity } from './identity.ts';
33
import { parseV1 } from './parse-v1.ts';
4-
import { applySuggestion, assertEditReply, importPlan, validatePlan, PlanError,
4+
import { applySuggestion, assertEditReply, importPlan, validateContinuationPlan, validatePlan, PlanError,
55
type Diagnostic, type EditReply, type Plan, type PlanContext } from './plan.ts';
66
import registry from '../schema/versions.json' with { type: 'json' };
77

@@ -19,6 +19,8 @@ export interface AuthorInput {
1919
approvedLessons: readonly string[];
2020
feedback: string;
2121
previousPlan?: Plan;
22+
completedItems?: readonly string[];
23+
continuationContext?: { checkpointId: string; head: string; completedItems: readonly string[]; ownerItem: string; outOfScopePaths: readonly string[] } | null;
2224
}
2325
export interface AuthorRequest {
2426
readonly mode: 'draft' | 'suggest';
@@ -39,7 +41,7 @@ export interface AuthorProvider {
3941
}
4042
export interface PreparedAuthor<T> {
4143
readonly request: AuthorRequest;
42-
validate(source: string | Uint8Array): { value: T; warnings: Diagnostic[] };
44+
validate(source: string | Uint8Array): { value: T; warnings: Diagnostic[]; candidatePlans: Plan[] };
4345
}
4446

4547
function integer(value: number, label: string): void {
@@ -93,8 +95,17 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
9395
const context: PlanContext = { ...input.context, identity: { repositoryId, taskId, planId },
9496
baseEntries: structuredClone(input.context.baseEntries), allowedCommands: structuredClone(input.context.allowedCommands) };
9597
const previous = input.previousPlan ? structuredClone(input.previousPlan) : undefined;
98+
const completedItems = input.completedItems === undefined || input.completedItems.length === 0
99+
? undefined : structuredClone([...input.completedItems]);
100+
if (completedItems && (!input.continuationContext || input.continuationContext.completedItems.length !== completedItems.length ||
101+
input.continuationContext.completedItems.some((item, index) => item !== completedItems[index])))
102+
throw new Error('Planning continuation context does not match its completed prefix.');
96103
if (previous) {
97-
const result = validatePlan(previous, context);
104+
// The existing suffix is context for the provider to repair, not a candidate. Validate its structure and the
105+
// checkpoint-owned prefix here; require the proposed candidate to pass full suffix semantics below.
106+
const result = completedItems === undefined ? validatePlan(previous, context) : validateContinuationPlan({
107+
...previous, items: previous.items.slice(0, completedItems.length),
108+
}, context, completedItems, true);
98109
if (result.errors.length) throw new PlanError(result.errors);
99110
if (input.revision !== previous.revision + (mode === 'draft' ? 1 : 0)) throw new Error('Previous plan revision mismatch.');
100111
} else if (mode === 'suggest') throw new Error('Suggestions require a previous plan.');
@@ -115,6 +126,9 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
115126
lessons_data_json: dataJSON(input.approvedLessons, 'Lessons'),
116127
feedback_data_json: dataJSON(input.feedback, 'Feedback'),
117128
previous_plan_json: dataJSON(previous ?? null, 'Previous plan'),
129+
continuation_context_instruction: input.continuationContext
130+
? `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`
131+
: '',
118132
};
119133
const conditional = template.replace(/\{\{#if previous_plan\}\}([\s\S]*?)\{\{\/if\}\}/gu, (_, block: string) => previous ? block : '');
120134
// One pass over trusted template only: inserted data is never interpreted again.
@@ -130,18 +144,20 @@ function prepare(input: AuthorInput, mode: AuthorRequest['mode']): PreparedAutho
130144
// importPlan replaces a revision for user imports; provider output must match it first.
131145
const data = parseV1(source, 'json');
132146
if ((data as Plan | null)?.revision !== request.revision) throw new Error('Response revision mismatch.');
133-
const result = importPlan(source, 'json', context, request.revision);
134-
return { value: result.plan, warnings: result.warnings };
147+
const result = importPlan(source, 'json', context, request.revision, completedItems);
148+
return { value: result.plan, warnings: result.warnings, candidatePlans: [result.plan] };
135149
}
136150
const reply = parseV1(source, 'json'); assertEditReply(reply);
137151
if (reply.base_revision !== request.revision) throw new Error('Response revision mismatch.');
138152
const warnings: Diagnostic[] = [];
153+
const candidatePlans: Plan[] = [];
139154
// Validate every independent card against the captured plan before exposing any card.
140155
for (let index = 0; index < reply.edits.length; index++) {
141156
const next = applySuggestion(previous!, reply, index, context, { identity: context.identity,
142-
schemaVersion: previous!.schema_version, baseRevision: request.revision, issue: context.issue });
143-
warnings.push(...validatePlan(next, context).warnings);
157+
schemaVersion: previous!.schema_version, baseRevision: request.revision, issue: context.issue }, completedItems);
158+
candidatePlans.push(next);
159+
warnings.push(...(completedItems === undefined ? validatePlan(next, context) : validateContinuationPlan(next, context, completedItems, true)).warnings);
144160
}
145-
return { value: reply, warnings };
161+
return { value: reply, warnings, candidatePlans };
146162
} });
147163
}

‎core/planning-suggestions.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { identityKey, type PlanIdentity } from './identity.ts';
22
import { prepareDraft, prepareSuggestions, type AuthorInput, type AuthorProvider, type PreparedAuthor } from './planning-author.ts';
3-
import type { Diagnostic, EditReply, Plan } from './plan.ts';
3+
import type { ContinuationBinding, Diagnostic, EditReply, Plan } from './plan.ts';
44

55
/** Implemented by the existing Store. No SQL or second persistence writer in core. */
66
export interface SuggestionStore {
77
getPlan(identity: PlanIdentity): Plan;
88
getSnapshot(identity: PlanIdentity): { id: string; base: string; head: string };
9-
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }, mode: PlanningMode): string;
10-
completeSuggestions(identity: PlanIdentity, id: string, reply: unknown): void;
9+
beginSuggestions(identity: PlanIdentity, expected: { revision: number; snapshotId: string }, mode: PlanningMode, continuation?: ContinuationBinding | null): string;
10+
completeSuggestions(identity: PlanIdentity, id: string, reply: unknown, candidatePlans?: Plan[]): boolean | void;
1111
settleSuggestion(identity: PlanIdentity, id: string, expected: { revision: number; snapshotId: string }, outcome: { state: 'failed' | 'cancelled' | 'invalidated'; reason: string }): boolean;
1212
getSuggestions(identity: PlanIdentity, id: string): { state: string; revision: number; snapshotId: string | null; reply: EditReply | null; reason: string | null };
1313
}
@@ -16,7 +16,7 @@ export interface SuggestionStore {
1616
* in the input is always the current revision the request is bound to.
1717
*/
1818
export type PlanningMode = 'suggest' | 'draft';
19-
export type SuggestionInput = Omit<AuthorInput, 'requestId' | 'previousPlan'> & { snapshotId: string };
19+
export type SuggestionInput = Omit<AuthorInput, 'requestId' | 'previousPlan'> & { snapshotId: string; continuationBinding?: ContinuationBinding | null };
2020
export type SuggestionOutcome =
2121
| { state: 'completed'; id: string; warnings: Diagnostic[] }
2222
| { state: 'failed' | 'cancelled' | 'stale'; id: string; reason: string };
@@ -57,6 +57,11 @@ export class SuggestionCoordinator {
5757
if (this.#closing) throw new Error('Planning coordinator is closing.');
5858
const identity = { ...input.context.identity }, key = identityKey(identity);
5959
if (this.#active.has(key)) throw new Error('A planning invocation is still active for this plan.');
60+
const completed = input.completedItems ?? [];
61+
if ((input.continuationBinding === null || input.continuationBinding === undefined) !== (completed.length === 0) ||
62+
(input.continuationBinding && (input.continuationBinding.completedItems.length !== completed.length ||
63+
input.continuationBinding.completedItems.some((item, index) => item !== completed[index]))))
64+
throw new Error('Planning continuation binding does not match its completed prefix.');
6065
const snapshotId = input.snapshotId;
6166
const previousPlan = this.#store.getPlan(identity), snapshot = this.#store.getSnapshot(identity);
6267
if (previousPlan.revision !== input.revision) throw new Error('Stale plan revision.');
@@ -67,7 +72,7 @@ export class SuggestionCoordinator {
6772
: prepareSuggestions({ ...input, requestId: 'pending', previousPlan });
6873
const expected = Object.freeze({ revision: input.revision, snapshotId });
6974
const noun = mode === 'draft' ? 'Draft' : 'Suggestion';
70-
const id = this.#store.beginSuggestions(identity, expected, mode);
75+
const id = this.#store.beginSuggestions(identity, expected, mode, input.continuationBinding ?? null);
7176
const request = Object.freeze({ ...prepared.request, requestId: id });
7277
const controller = new AbortController();
7378
let stopped: Omit<TerminalOutcome, 'id'> | undefined;
@@ -119,8 +124,9 @@ export class SuggestionCoordinator {
119124
outcome = { id, state: current.state === 'cancelled' ? 'cancelled' : 'stale', reason: `${noun} request is ${current.state}.` };
120125
} else {
121126
const validated = prepared.validate(source);
122-
this.#store.completeSuggestions(identity, id, validated.value);
123-
outcome = { id, state: 'completed', warnings: validated.warnings };
127+
const saved = this.#store.completeSuggestions(identity, id, validated.value, validated.candidatePlans);
128+
outcome = saved === false ? { id, state: 'stale', reason: 'Checkpoint continuation changed during authoring.' }
129+
: { id, state: 'completed', warnings: validated.warnings };
124130
}
125131
}
126132
}

0 commit comments

Comments
 (0)