Skip to content

Commit 47e6f2e

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 3ef09d9 commit 47e6f2e

4 files changed

Lines changed: 318 additions & 16 deletions

File tree

‎runner/coordinator.ts‎

Lines changed: 45 additions & 16 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, type UnreleasedResource } 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
* The runner token D labels every resource with (32 lowercase hex characters). `prepare` must allocate task storage
@@ -29,6 +35,14 @@ export interface RunnerDeps {
2935
start(input: InvocationInput, prepared: PreparedAttempt): InvocationHandle;
3036
/** Validate a clean result; throw with an actionable reason if it is invalid. Returns the value to persist. */
3137
validate(attempt: AttemptRecord, result: InvocationResult): unknown;
38+
/**
39+
* Optional asynchronous replacement for validate, used by writable attempts: audit, make the runner commit inside
40+
* task storage, and return the value plus the ledger record. Nothing is written to the Store here; the record is
41+
* saved with `completed` in one transaction. Throw FinishFailure with an actionable diagnostic to fail the attempt.
42+
*/
43+
finish?(attempt: AttemptRecord, result: InvocationResult, prepared: PreparedAttempt, signal: AbortSignal): Promise<{ value: unknown; history?: HistoryRecord }>;
44+
/** Optional: remove task storage after the terminal write and before the slot is freed. A failure keeps the slot under a marker. */
45+
release?(attempt: AttemptRecord, prepared: PreparedAttempt): Promise<void>;
3246
now?(): number;
3347
}
3448
export interface SlotLimits { readonly writable: number; readonly readOnly: number }
@@ -246,27 +260,27 @@ export class RunnerCoordinator {
246260
let prepared: PreparedAttempt;
247261
try { prepared = await this.#deps.prepare(attempt, job.controller.signal); }
248262
catch (error) { return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job, error)); }
249-
if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job));
263+
if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job), prepared);
250264
// Launch check: one synchronous turn, no await between the checks and D's start call.
251265
const now = this.#now(), row = this.#store.getAttempt(job.identity, attempt.id), task = this.#store.getTask(job.identity);
252266
if (row.firstReason && !job.firstReason) job.firstReason = row.firstReason;
253-
if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {});
267+
if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {}, prepared);
254268
// A context change comes before both time checks, as in the settlement order and startup recovery.
255269
if (!sameContext(row.context, this.#store.currentContext(job.identity))) {
256270
// Recorded like any stale stop, so the row keeps it even if a cancel task lands during cleanup.
257271
this.#requestStop(job, 'stale');
258-
return await this.#endBeforeLaunch(job, attempt, {});
272+
return await this.#endBeforeLaunch(job, attempt, {}, prepared);
259273
}
260-
if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}); }
261-
if (now >= attempt.deadline) { job.preparationTimedOut = true; return await this.#endBeforeLaunch(job, attempt, { detail: PREPARATION_TIMEOUT }); }
274+
if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}, prepared); }
275+
if (now >= attempt.deadline) { job.preparationTimedOut = true; return await this.#endBeforeLaunch(job, attempt, { detail: PREPARATION_TIMEOUT }, prepared); }
262276
// Fail closed: once D reported resources it could not remove, no new invocation starts, even one already admitted.
263-
if (this.#unreleased) return await this.#endBeforeLaunch(job, attempt, { detail: NOT_STARTED_UNRELEASED });
277+
if (this.#unreleased) return await this.#endBeforeLaunch(job, attempt, { detail: NOT_STARTED_UNRELEASED }, prepared);
264278
let handle: InvocationHandle;
265279
try {
266280
const input = captureInvocation({ clone: prepared.clone, phase: ATTEMPT_PHASES[attempt.kind], vendor: prepared.vendor,
267281
approvedArgv: prepared.approvedArgv, deadline: attempt.deadline, attemptId: attempt.id, runnerOwner: this.#deps.runnerOwner, context: attempt.context }, now);
268282
handle = this.#deps.start(input, prepared);
269-
} catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }); }
283+
} catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }, prepared); }
270284
job.handle = handle;
271285
let running: boolean | undefined;
272286
try { running = this.#write(() => this.#store.markRunning(job.identity, attempt.id)); } catch { running = undefined; }
@@ -291,20 +305,27 @@ export class RunnerCoordinator {
291305
// Accept only the result of this exact invocation, as the question path does. Anything else is never validated
292306
// or saved: the attempt fails closed.
293307
if (result.attemptId !== attempt.id || !result.context || !sameContext(result.context, attempt.context)) {
294-
this.#settle(job, { stopReason: 'capture-failure', exitCode: null, signal: null, valid: false,
308+
const foreignSaved = this.#settle(job, { stopReason: 'capture-failure', exitCode: null, signal: null, valid: false,
295309
detail: job.firstReason === 'stale' ? job.staleCause : FOREIGN_RESULT });
296310
job.decided = true;
311+
// Task storage waits for the terminal write, as on every other path.
312+
if (foreignSaved) await this.#release(job, attempt, prepared);
297313
return;
298314
}
299-
let valid = false, value: unknown, detail = result.stderr ? bounded(result.stderr) : undefined;
315+
let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? bounded(result.stderr) : undefined;
300316
if (!job.firstReason && result.exitCode === 0 && !result.stopReason) {
301-
try { value = this.#deps.validate(attempt, result); valid = true; }
302-
catch (error) { detail = `Invalid output: ${message(error)}`; }
317+
try {
318+
if (this.#deps.finish) { const done = await this.#deps.finish(attempt, result, prepared, job.controller.signal); value = done.value; history = done.history; }
319+
else value = this.#deps.validate(attempt, result);
320+
valid = true;
321+
} catch (error) { detail = error instanceof FinishFailure ? bounded(error.message) : `Invalid output: ${message(error)}`; }
303322
}
304323
// A stale stop keeps its own cause; the agent's stderr is not a reason the attempt went stale.
305324
if (job.firstReason === 'stale') detail = job.staleCause;
306-
const saved = this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail });
325+
const saved = this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail, history });
307326
job.decided = true;
327+
// Task storage goes after the terminal write too; a failed removal holds the slot under a marker.
328+
if (saved) await this.#release(job, attempt, prepared);
308329
// Host-side preparation files go after the terminal write, so a failed write leaves them for startup recovery.
309330
if (saved && !(await this.#removePreparation(job, attempt))) this.#holdForPreparation(job);
310331
} catch (error) {
@@ -327,11 +348,13 @@ export class RunnerCoordinator {
327348
return error === undefined || job.firstReason ? {} : { detail: `Preparation failed: ${message(error)}` };
328349
}
329350
/** Ending without a handle: host-side cleanup, then the terminal write from the first reason. */
330-
async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { detail?: string }): Promise<void> {
351+
async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { detail?: string }, prepared?: PreparedAttempt): Promise<void> {
331352
// Stops that land while preparation finishes are taken into account; once the job is ending, the outcome is fixed.
332353
job.decided = true;
333354
const removed = await this.#removePreparation(job, attempt);
334-
this.#settle(job, { exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : s.detail });
355+
const saved = this.#settle(job, { exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : s.detail });
356+
// Task storage (if preparation allocated it) waits for the terminal write, like every other path.
357+
if (saved && prepared) await this.#release(job, attempt, prepared);
335358
if (!removed) this.#holdForPreparation(job);
336359
}
337360
/**
@@ -349,7 +372,13 @@ export class RunnerCoordinator {
349372
#holdForPreparation(job: Job): void {
350373
if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'preparation-not-removed' });
351374
}
352-
#settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string }): Classification | undefined {
375+
/** After the terminal write: remove task storage, then the slot is freed. A failure keeps the slot under a marker. */
376+
async #release(job: Job, attempt: AttemptRecord, prepared: PreparedAttempt): Promise<void> {
377+
if (!this.#deps.release) return;
378+
try { await this.#deps.release(attempt, prepared); }
379+
catch { if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'result-not-saved' }); }
380+
}
381+
#settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string; history?: HistoryRecord }): Classification | undefined {
353382
try {
354383
return this.#write(() => this.#store.settleAttempt(job.identity, job.attemptId, { ...s, firstReason: job.firstReason }));
355384
} catch {

0 commit comments

Comments
 (0)