Skip to content

Commit f0957ad

Browse files
mchwangclaude
andcommitted
F2b: per-item execution and the runner's commit step
ItemExecutor runs a task's plan items in order as execute attempts. executionDeps materializes a fresh workspace and snapshots declared links, builds the prompt with prepareExecution, and in finish() inspects changes, audits them with auditRun, and makes the runner's own commit with Plan-Item/Plan-Revision trailers. The coordinator gains an async finish step and a release step after the terminal write; settleAttempt records the owned ledger entry with completed in the same transaction. A safety violation moves the task to needs human; out-of-scope files are committed and pause it in needs amendment with a checkpoint. The workspace is D's (#66), faked here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c1e2277 commit f0957ad

4 files changed

Lines changed: 313 additions & 14 deletions

File tree

‎runner/coordinator.ts‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
import { identityKey, type PlanIdentity } from '../core/identity.ts';
22
import { captureInvocation, type InvocationHandle, type InvocationInput, type InvocationResult, type StopReason, type TaskClone } from '../agents/contract.ts';
3-
import type { AttemptRecord, Store } from './store.ts';
3+
import type { AttemptRecord, LedgerEntry, Store } from './store.ts';
44
import { ATTEMPT_PHASES, GuardRefusal, ShuttingDownError, WRITABLE_KINDS, bounded, sameContext, type AttemptKind, type Classification, type FirstReason, type ShutdownCapability } from './lifecycle.ts';
55

66
/** What F's host-side preparation hands to D's start call. */
77
export interface PreparedAttempt {
88
readonly clone: TaskClone;
99
readonly vendor: 'claude' | 'codex';
1010
readonly approvedArgv: readonly (readonly string[])[];
11+
/** Opaque data the deps keep for their own finish/release steps (for example the task workspace). */
12+
readonly private?: unknown;
1113
}
14+
/** The ledger record saved with `completed` in the same transaction (runner-lifecycle.md, publication step 3). */
15+
export interface HistoryRecord { readonly base: string; readonly head: string; readonly entries: readonly LedgerEntry[] }
16+
/** A finish step's failure with its own actionable diagnostic (for example a safety violation). */
17+
export class FinishFailure extends Error {}
1218
export interface RunnerDeps {
1319
/**
1420
* Host-side preparation (clone, prompt). On abort it must stop and await every subprocess it started, then reject.
@@ -21,6 +27,14 @@ export interface RunnerDeps {
2127
start(input: InvocationInput, prepared: PreparedAttempt): InvocationHandle;
2228
/** Validate a clean result; throw with an actionable reason if it is invalid. Returns the value to persist. */
2329
validate(attempt: AttemptRecord, result: InvocationResult): unknown;
30+
/**
31+
* Optional asynchronous replacement for validate, used by writable attempts: audit, make the runner commit inside
32+
* task storage, and return the value plus the ledger record. Nothing is written to the Store here; the record is
33+
* saved with `completed` in one transaction. Throw FinishFailure with an actionable diagnostic to fail the attempt.
34+
*/
35+
finish?(attempt: AttemptRecord, result: InvocationResult, prepared: PreparedAttempt, signal: AbortSignal): Promise<{ value: unknown; history?: HistoryRecord }>;
36+
/** Optional: remove task storage after the terminal write and before the slot is freed. A failure keeps the slot under a marker. */
37+
release?(attempt: AttemptRecord, prepared: PreparedAttempt): Promise<void>;
2438
now?(): number;
2539
}
2640
export interface SlotLimits { readonly writable: number; readonly readOnly: number }
@@ -168,20 +182,20 @@ export class RunnerCoordinator {
168182
let prepared: PreparedAttempt;
169183
try { prepared = await this.#deps.prepare(attempt, job.controller.signal); }
170184
catch (error) { return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job, error)); }
171-
if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job));
185+
if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job), prepared);
172186
// Launch check: one synchronous turn, no await between the checks and D's start call.
173187
const now = this.#now(), row = this.#store.getAttempt(job.identity, attempt.id), task = this.#store.getTask(job.identity);
174188
if (row.firstReason && !job.firstReason) job.firstReason = row.firstReason;
175-
if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {});
176-
if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}); }
177-
if (now >= attempt.deadline) return await this.#endBeforeLaunch(job, attempt, { stopReason: 'timeout', detail: 'Timed out while preparing.' });
178-
if (!sameContext(row.context, this.#store.currentContext(job.identity))) return await this.#endBeforeLaunch(job, attempt, {});
189+
if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {}, prepared);
190+
if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}, prepared); }
191+
if (now >= attempt.deadline) return await this.#endBeforeLaunch(job, attempt, { stopReason: 'timeout', detail: 'Timed out while preparing.' }, prepared);
192+
if (!sameContext(row.context, this.#store.currentContext(job.identity))) return await this.#endBeforeLaunch(job, attempt, {}, prepared);
179193
let handle: InvocationHandle;
180194
try {
181195
const input = captureInvocation({ clone: prepared.clone, phase: ATTEMPT_PHASES[attempt.kind], vendor: prepared.vendor,
182196
approvedArgv: prepared.approvedArgv, deadline: attempt.deadline, attemptId: attempt.id, context: attempt.context }, now);
183197
handle = this.#deps.start(input, prepared);
184-
} catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }); }
198+
} catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }, prepared); }
185199
job.handle = handle;
186200
let running: boolean | undefined;
187201
try { running = this.#write(() => this.#store.markRunning(job.identity, attempt.id)); } catch { running = undefined; }
@@ -199,12 +213,16 @@ export class RunnerCoordinator {
199213
handle.cancel(D_REASON[job.firstReason ?? 'cancelled']);
200214
} else if (job.firstReason) handle.cancel(D_REASON[job.firstReason]);
201215
const result = await handle.settled;
202-
let valid = false, value: unknown, detail = result.stderr ? bounded(result.stderr) : undefined;
216+
let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? bounded(result.stderr) : undefined;
203217
if (!job.firstReason && result.exitCode === 0 && !result.stopReason) {
204-
try { value = this.#deps.validate(attempt, result); valid = true; }
205-
catch (error) { detail = `Invalid output: ${message(error)}`; }
218+
try {
219+
if (this.#deps.finish) { const done = await this.#deps.finish(attempt, result, prepared, job.controller.signal); value = done.value; history = done.history; }
220+
else value = this.#deps.validate(attempt, result);
221+
valid = true;
222+
} catch (error) { detail = error instanceof FinishFailure ? bounded(error.message) : `Invalid output: ${message(error)}`; }
206223
}
207-
this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail });
224+
if (this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail, history }))
225+
await this.#release(job, attempt, prepared);
208226
} finally {
209227
for (const timer of job.timers) clearTimeout(timer);
210228
if (this.#jobs.get(job.key) === job) this.#jobs.delete(job.key);
@@ -215,11 +233,19 @@ export class RunnerCoordinator {
215233
return error === undefined || job.firstReason ? {} : { detail: `Preparation failed: ${message(error)}` };
216234
}
217235
/** Ending without a handle: host-side cleanup, then the terminal write from the first reason. */
218-
async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { stopReason?: StopReason; detail?: string }): Promise<void> {
236+
async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { stopReason?: StopReason; detail?: string }, prepared?: PreparedAttempt): Promise<void> {
219237
await this.#deps.cleanupPreparation(attempt).catch(() => undefined);
220-
this.#settle(job, { stopReason: s.stopReason, exitCode: null, signal: null, valid: false, detail: s.detail });
238+
// Task storage (if preparation allocated it) waits for the terminal write, like every other path.
239+
if (this.#settle(job, { stopReason: s.stopReason, exitCode: null, signal: null, valid: false, detail: s.detail }) && prepared)
240+
await this.#release(job, attempt, prepared);
241+
}
242+
/** After the terminal write: remove task storage, then the slot is freed. A failure keeps the slot under a marker. */
243+
async #release(job: Job, attempt: AttemptRecord, prepared: PreparedAttempt): Promise<void> {
244+
if (!this.#deps.release) return;
245+
try { await this.#deps.release(attempt, prepared); }
246+
catch { this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'result-not-saved' }); }
221247
}
222-
#settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string }): Classification | undefined {
248+
#settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string; history?: HistoryRecord }): Classification | undefined {
223249
try {
224250
return this.#write(() => this.#store.settleAttempt(job.identity, job.attemptId, { ...s, firstReason: job.firstReason }));
225251
} catch {

‎runner/execution.ts‎

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import type { PlanIdentity } from '../core/identity.ts';
2+
import type { PlanContext } from '../core/plan.ts';
3+
import type { InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts';
4+
import { prepareExecution } from '../core/execution-prompt.ts';
5+
import { auditRun, type ChangeManifest } from '../core/run-audit.ts';
6+
import { FinishFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts';
7+
import type { AttemptRecord, Store } from './store.ts';
8+
9+
/**
10+
* F2b: per-item execution and the runner's commit step (design, "How codeboost runs a plan"; plan-format.md, "After
11+
* each run"). The workspace operations are D's (#66); until they exist this module is exercised with a fake.
12+
*/
13+
export interface WorkspaceRef { readonly clone: TaskClone; readonly storage: unknown }
14+
export interface TaskWorkspace {
15+
/** A fresh task filesystem from the recorded trusted head; never a reset of a used one. Abortable; settles only when its work stopped. */
16+
materialize(attempt: AttemptRecord, head: string, signal: AbortSignal): Promise<WorkspaceRef>;
17+
/** No-follow snapshot of every declared symlink target, taken before launch. */
18+
snapshotDeclaredLinks(workspace: WorkspaceRef, paths: readonly string[], signal: AbortSignal): Promise<unknown>;
19+
/** The change manifest after the agent settled, plus a digest the commit step must match. */
20+
inspectChanges(workspace: WorkspaceRef, input: { baseHead: string; linkSnapshot: unknown }, signal: AbortSignal): Promise<ChangeManifest & { digest: string }>;
21+
/** Undo agent commits (keeping changes), stage exactly `paths`, commit with hooks off; refuse if the tree no longer matches `digest`. */
22+
commit(workspace: WorkspaceRef, input: { baseHead: string; paths: readonly string[]; message: string; trailers: Readonly<Record<string, string>>; digest: string }, signal: AbortSignal): Promise<string>;
23+
release(workspace: WorkspaceRef): Promise<void>;
24+
}
25+
/** D's start call for an execute/fix phase with this prompt; returns at once (see #51). */
26+
export type AgentLauncher = (input: InvocationInput, prompt: string, workspace: WorkspaceRef) => InvocationHandle;
27+
/** Trusted runner-side sources for a task. Issue text and lessons are untrusted data inside the prompt. */
28+
export interface ExecutionSources {
29+
planContext(identity: PlanIdentity): PlanContext;
30+
issue(identity: PlanIdentity): { number: number; title: string; body: string; comments: readonly string[] };
31+
lessons(identity: PlanIdentity): readonly string[];
32+
vendor(identity: PlanIdentity): 'claude' | 'codex';
33+
}
34+
/** Prefix of the diagnostic for an audit safety violation; the executor moves the task to needs human on it. */
35+
export const SAFETY_VIOLATION = 'Safety violation:';
36+
export interface ExecutionResult { head: string; unchanged: boolean; inScope: string[]; outOfScope: string[] }
37+
interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; linkSnapshot: unknown }
38+
39+
/** RunnerDeps for execute attempts: fresh workspace, prompt, agent, then audit and the runner's own commit. */
40+
export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources): RunnerDeps {
41+
const identityOf = (attempt: AttemptRecord): PlanIdentity => findIdentity(store, attempt);
42+
return {
43+
async prepare(attempt, signal) {
44+
if (attempt.kind !== 'execute' || !attempt.item) throw new Error('Execution deps run execute attempts for one plan item.');
45+
const identity = identityOf(attempt), plan = store.getPlan(identity, attempt.context.planRevision);
46+
const item = plan.items.find(entry => entry.id === attempt.item)!;
47+
const context = sources.planContext(identity);
48+
const baseHead = store.getSnapshot(identity, attempt.context.snapshotId).head;
49+
const request = prepareExecution({ identity, attemptId: attempt.id, mode: 'execute', plan, itemId: item.id,
50+
issue: sources.issue(identity), approvedLessons: sources.lessons(identity), allowedCommands: context.allowedCommands });
51+
const ws = await workspace.materialize(attempt, baseHead, signal);
52+
const declaredLinks = item.files.map(file => file.path).filter(path => context.baseEntries.some(entry => entry.kind === 'symlink' && context.pathKey(entry.path) === context.pathKey(path)));
53+
const linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredLinks, signal);
54+
const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot };
55+
return { clone: ws.clone, vendor: sources.vendor(identity), approvedArgv: request.approvedArgv, private: data };
56+
},
57+
async cleanupPreparation() { /* host-side files belong to D's materialize; task storage waits for release */ },
58+
start(input, prepared) { const data = prepared.private as Private; return launch(input, data.prompt, data.workspace); },
59+
validate() { throw new Error('Execute attempts publish through finish().'); },
60+
async finish(attempt, _result, prepared, signal) {
61+
const data = prepared.private as Private, identity = identityOf(attempt);
62+
const plan = store.getPlan(identity, attempt.context.planRevision), item = plan.items.find(entry => entry.id === attempt.item)!;
63+
const manifest = await workspace.inspectChanges(data.workspace, { baseHead: data.baseHead, linkSnapshot: data.linkSnapshot }, signal);
64+
const outcome = auditRun(item, manifest, sources.planContext(identity).pathKey);
65+
if (outcome.kind === 'violation') throw new FinishFailure(`${SAFETY_VIOLATION} ${outcome.violations.join(' ')}`);
66+
if (outcome.unchanged) return { value: { head: data.baseHead, unchanged: true, inScope: [], outOfScope: [] } satisfies ExecutionResult };
67+
const head = await workspace.commit(data.workspace, {
68+
baseHead: data.baseHead, paths: [...outcome.inScope, ...outcome.outOfScope], digest: manifest.digest,
69+
message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` },
70+
}, signal);
71+
const snapshot = store.getSnapshot(identity, attempt.context.snapshotId);
72+
return {
73+
value: { head, unchanged: false, inScope: outcome.inScope, outOfScope: outcome.outOfScope } satisfies ExecutionResult,
74+
history: { base: snapshot.base, head, entries: [{ sha: head, owner: item.id, origin: 'owned', sourceSha: null }] },
75+
};
76+
},
77+
async release(_attempt, prepared) {
78+
const data = prepared.private as Private;
79+
await workspace.release(data.workspace);
80+
},
81+
};
82+
}
83+
/** The plan identity that owns an attempt; attempts are stored per plan key. */
84+
function findIdentity(store: Store, attempt: AttemptRecord): PlanIdentity {
85+
const key = store.attemptOwner(attempt.id);
86+
if (!key) throw new Error('Unknown attempt.');
87+
const [repositoryId, taskId, planId] = JSON.parse(key) as string[];
88+
return { repositoryId: repositoryId!, taskId: taskId!, planId: planId! };
89+
}
90+
91+
export type ExecutionOutcome =
92+
| { kind: 'executed'; items: string[]; unchanged: string[] }
93+
| { kind: 'needs amendment'; item: string; outOfScope: string[]; checkpointId: string }
94+
| { kind: 'needs human'; item: string; reason: string }
95+
| { kind: 'stopped'; item: string; state: string; reason: string | null };
96+
97+
/**
98+
* Runs a task's plan items in order, one execute attempt each. Stops at the first item that does not complete cleanly:
99+
* out-of-scope files pause the task in needs amendment with a checkpoint; a safety violation moves it to needs human.
100+
*/
101+
export class ItemExecutor {
102+
#store: Store; #runner: RunnerCoordinator; #sources: ExecutionSources;
103+
#deadlineMs: number;
104+
constructor(store: Store, runner: RunnerCoordinator, sources: ExecutionSources, deadlineMs = 10 * 60_000) {
105+
this.#store = store; this.#runner = runner; this.#sources = sources; this.#deadlineMs = deadlineMs;
106+
}
107+
async runTask(identity: PlanIdentity, options: { fromItem?: string } = {}): Promise<ExecutionOutcome> {
108+
const plan = this.#store.getPlan(identity);
109+
const start = options.fromItem ? plan.items.findIndex(item => item.id === options.fromItem) : 0;
110+
if (start < 0) throw new Error('Unknown plan item.');
111+
const done: string[] = [], unchanged: string[] = [];
112+
for (const item of plan.items.slice(start)) {
113+
const attempt = this.#runner.start(identity, {
114+
expectedStateVersion: this.#store.getTask(identity).stateVersion, kind: 'execute', item: item.id,
115+
expectedContext: this.#store.currentContext(identity), deadline: Date.now() + this.#deadlineMs,
116+
});
117+
await this.#runner.settled(identity);
118+
const row = this.#store.getAttempt(identity, attempt.id);
119+
if (row.state !== 'completed') {
120+
if (row.state === 'failed' && row.diagnostic?.startsWith(SAFETY_VIOLATION)) {
121+
this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs human');
122+
return { kind: 'needs human', item: item.id, reason: row.diagnostic };
123+
}
124+
return { kind: 'stopped', item: item.id, state: row.state, reason: row.diagnostic };
125+
}
126+
const result = row.result as ExecutionResult;
127+
done.push(item.id);
128+
if (result.unchanged) unchanged.push(item.id);
129+
if (result.outOfScope.length) {
130+
const view = { revision: this.#store.getPlan(identity).revision, snapshotId: this.#store.getSnapshot(identity).id };
131+
const checkpoint = this.#store.recordCheckpoint(identity, view, {
132+
item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries,
133+
completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope,
134+
});
135+
this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs amendment');
136+
return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId: checkpoint.id };
137+
}
138+
}
139+
return { kind: 'executed', items: done, unchanged };
140+
}
141+
}

0 commit comments

Comments
 (0)