From 0042dbb8cfe36581d43b8d832f7c10bfd22f561f Mon Sep 17 00:00:00 2001 From: mchwang Date: Sun, 27 Sep 2026 01:21:21 -0700 Subject: [PATCH 01/26] 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 --- runner/coordinator.ts | 61 +++++++++++---- runner/execution.ts | 141 ++++++++++++++++++++++++++++++++++ runner/store.ts | 7 ++ test/runner-execution.test.ts | 125 ++++++++++++++++++++++++++++++ 4 files changed, 318 insertions(+), 16 deletions(-) create mode 100644 runner/execution.ts create mode 100644 test/runner-execution.test.ts diff --git a/runner/coordinator.ts b/runner/coordinator.ts index fd3aea1e..8473c1e4 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -1,6 +1,6 @@ import { identityKey, type PlanIdentity } from '../core/identity.ts'; import { captureInvocation, type InvocationHandle, type InvocationInput, type InvocationResult, type StopReason, type TaskClone, type UnreleasedResource } from '../agents/contract.ts'; -import type { AttemptRecord, Store } from './store.ts'; +import type { AttemptRecord, LedgerEntry, Store } from './store.ts'; import { ATTEMPT_PHASES, GuardRefusal, ShuttingDownError, WRITABLE_KINDS, bounded, sameContext, type AttemptKind, type Classification, type FirstReason, type ShutdownCapability, settleWith } from './lifecycle.ts'; /** What F's host-side preparation hands to D's start call. */ @@ -8,7 +8,13 @@ export interface PreparedAttempt { readonly clone: TaskClone; readonly vendor: 'claude' | 'codex'; readonly approvedArgv: readonly (readonly string[])[]; + /** Opaque data the deps keep for their own finish/release steps (for example the task workspace). */ + readonly private?: unknown; } +/** The ledger record saved with `completed` in the same transaction (runner-lifecycle.md, publication step 3). */ +export interface HistoryRecord { readonly base: string; readonly head: string; readonly entries: readonly LedgerEntry[] } +/** A finish step's failure with its own actionable diagnostic (for example a safety violation). */ +export class FinishFailure extends Error {} export interface RunnerDeps { /** * The runner token D labels every resource with (32 lowercase hex characters). `prepare` must allocate task storage @@ -29,6 +35,14 @@ export interface RunnerDeps { start(input: InvocationInput, prepared: PreparedAttempt): InvocationHandle; /** Validate a clean result; throw with an actionable reason if it is invalid. Returns the value to persist. */ validate(attempt: AttemptRecord, result: InvocationResult): unknown; + /** + * Optional asynchronous replacement for validate, used by writable attempts: audit, make the runner commit inside + * task storage, and return the value plus the ledger record. Nothing is written to the Store here; the record is + * saved with `completed` in one transaction. Throw FinishFailure with an actionable diagnostic to fail the attempt. + */ + finish?(attempt: AttemptRecord, result: InvocationResult, prepared: PreparedAttempt, signal: AbortSignal): Promise<{ value: unknown; history?: HistoryRecord }>; + /** Optional: remove task storage after the terminal write and before the slot is freed. A failure keeps the slot under a marker. */ + release?(attempt: AttemptRecord, prepared: PreparedAttempt): Promise; now?(): number; } export interface SlotLimits { readonly writable: number; readonly readOnly: number } @@ -246,27 +260,27 @@ export class RunnerCoordinator { let prepared: PreparedAttempt; try { prepared = await this.#deps.prepare(attempt, job.controller.signal); } catch (error) { return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job, error)); } - if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job)); + if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job), prepared); // Launch check: one synchronous turn, no await between the checks and D's start call. const now = this.#now(), row = this.#store.getAttempt(job.identity, attempt.id), task = this.#store.getTask(job.identity); if (row.firstReason && !job.firstReason) job.firstReason = row.firstReason; - if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {}); + if (row.state !== 'pending' || job.firstReason) return await this.#endBeforeLaunch(job, attempt, {}, prepared); // A context change comes before both time checks, as in the settlement order and startup recovery. if (!sameContext(row.context, this.#store.currentContext(job.identity))) { // Recorded like any stale stop, so the row keeps it even if a cancel task lands during cleanup. this.#requestStop(job, 'stale'); - return await this.#endBeforeLaunch(job, attempt, {}); + return await this.#endBeforeLaunch(job, attempt, {}, prepared); } - if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}); } - if (now >= attempt.deadline) { job.preparationTimedOut = true; return await this.#endBeforeLaunch(job, attempt, { detail: PREPARATION_TIMEOUT }); } + if (task.budgetDeadline !== null && now >= task.budgetDeadline) { this.#requestStop(job, 'time-limit'); return await this.#endBeforeLaunch(job, attempt, {}, prepared); } + if (now >= attempt.deadline) { job.preparationTimedOut = true; return await this.#endBeforeLaunch(job, attempt, { detail: PREPARATION_TIMEOUT }, prepared); } // Fail closed: once D reported resources it could not remove, no new invocation starts, even one already admitted. - if (this.#unreleased) return await this.#endBeforeLaunch(job, attempt, { detail: NOT_STARTED_UNRELEASED }); + if (this.#unreleased) return await this.#endBeforeLaunch(job, attempt, { detail: NOT_STARTED_UNRELEASED }, prepared); let handle: InvocationHandle; try { const input = captureInvocation({ clone: prepared.clone, phase: ATTEMPT_PHASES[attempt.kind], vendor: prepared.vendor, approvedArgv: prepared.approvedArgv, deadline: attempt.deadline, attemptId: attempt.id, runnerOwner: this.#deps.runnerOwner, context: attempt.context }, now); handle = this.#deps.start(input, prepared); - } catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }); } + } catch (error) { return await this.#endBeforeLaunch(job, attempt, { detail: `Launch failed: ${message(error)}` }, prepared); } job.handle = handle; let running: boolean | undefined; try { running = this.#write(() => this.#store.markRunning(job.identity, attempt.id)); } catch { running = undefined; } @@ -291,20 +305,27 @@ export class RunnerCoordinator { // Accept only the result of this exact invocation, as the question path does. Anything else is never validated // or saved: the attempt fails closed. if (result.attemptId !== attempt.id || !result.context || !sameContext(result.context, attempt.context)) { - this.#settle(job, { stopReason: 'capture-failure', exitCode: null, signal: null, valid: false, + const foreignSaved = this.#settle(job, { stopReason: 'capture-failure', exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : FOREIGN_RESULT }); job.decided = true; + // Task storage waits for the terminal write, as on every other path. + if (foreignSaved) await this.#release(job, attempt, prepared); return; } - let valid = false, value: unknown, detail = result.stderr ? bounded(result.stderr) : undefined; + let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? bounded(result.stderr) : undefined; if (!job.firstReason && result.exitCode === 0 && !result.stopReason) { - try { value = this.#deps.validate(attempt, result); valid = true; } - catch (error) { detail = `Invalid output: ${message(error)}`; } + try { + if (this.#deps.finish) { const done = await this.#deps.finish(attempt, result, prepared, job.controller.signal); value = done.value; history = done.history; } + else value = this.#deps.validate(attempt, result); + valid = true; + } catch (error) { detail = error instanceof FinishFailure ? bounded(error.message) : `Invalid output: ${message(error)}`; } } // A stale stop keeps its own cause; the agent's stderr is not a reason the attempt went stale. if (job.firstReason === 'stale') detail = job.staleCause; - const saved = this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail }); + const saved = this.#settle(job, { stopReason: result.stopReason, exitCode: result.exitCode, signal: result.signal, valid, result: value, detail, history }); job.decided = true; + // Task storage goes after the terminal write too; a failed removal holds the slot under a marker. + if (saved) await this.#release(job, attempt, prepared); // Host-side preparation files go after the terminal write, so a failed write leaves them for startup recovery. if (saved && !(await this.#removePreparation(job, attempt))) this.#holdForPreparation(job); } catch (error) { @@ -327,11 +348,13 @@ export class RunnerCoordinator { return error === undefined || job.firstReason ? {} : { detail: `Preparation failed: ${message(error)}` }; } /** Ending without a handle: host-side cleanup, then the terminal write from the first reason. */ - async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { detail?: string }): Promise { + async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { detail?: string }, prepared?: PreparedAttempt): Promise { // Stops that land while preparation finishes are taken into account; once the job is ending, the outcome is fixed. job.decided = true; const removed = await this.#removePreparation(job, attempt); - this.#settle(job, { exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : s.detail }); + const saved = this.#settle(job, { exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : s.detail }); + // Task storage (if preparation allocated it) waits for the terminal write, like every other path. + if (saved && prepared) await this.#release(job, attempt, prepared); if (!removed) this.#holdForPreparation(job); } /** @@ -349,7 +372,13 @@ export class RunnerCoordinator { #holdForPreparation(job: Job): void { if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'preparation-not-removed' }); } - #settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string }): Classification | undefined { + /** After the terminal write: remove task storage, then the slot is freed. A failure keeps the slot under a marker. */ + async #release(job: Job, attempt: AttemptRecord, prepared: PreparedAttempt): Promise { + if (!this.#deps.release) return; + try { await this.#deps.release(attempt, prepared); } + catch { if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'result-not-saved' }); } + } + #settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string; history?: HistoryRecord }): Classification | undefined { try { return this.#write(() => this.#store.settleAttempt(job.identity, job.attemptId, { ...s, firstReason: job.firstReason })); } catch { diff --git a/runner/execution.ts b/runner/execution.ts new file mode 100644 index 00000000..258a5f1c --- /dev/null +++ b/runner/execution.ts @@ -0,0 +1,141 @@ +import type { PlanIdentity } from '../core/identity.ts'; +import type { PlanContext } from '../core/plan.ts'; +import type { InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; +import { prepareExecution } from '../core/execution-prompt.ts'; +import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; +import { FinishFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; +import type { AttemptRecord, Store } from './store.ts'; + +/** + * F2b: per-item execution and the runner's commit step (design, "How codeboost runs a plan"; plan-format.md, "After + * each run"). The workspace operations are D's (#66); until they exist this module is exercised with a fake. + */ +export interface WorkspaceRef { readonly clone: TaskClone; readonly storage: unknown } +export interface TaskWorkspace { + /** A fresh task filesystem from the recorded trusted head; never a reset of a used one. Abortable; settles only when its work stopped. */ + materialize(attempt: AttemptRecord, head: string, signal: AbortSignal): Promise; + /** No-follow snapshot of every declared symlink target, taken before launch. */ + snapshotDeclaredLinks(workspace: WorkspaceRef, paths: readonly string[], signal: AbortSignal): Promise; + /** The change manifest after the agent settled, plus a digest the commit step must match. */ + inspectChanges(workspace: WorkspaceRef, input: { baseHead: string; linkSnapshot: unknown }, signal: AbortSignal): Promise; + /** Undo agent commits (keeping changes), stage exactly `paths`, commit with hooks off; refuse if the tree no longer matches `digest`. */ + commit(workspace: WorkspaceRef, input: { baseHead: string; paths: readonly string[]; message: string; trailers: Readonly>; digest: string }, signal: AbortSignal): Promise; + release(workspace: WorkspaceRef): Promise; +} +/** D's start call for an execute/fix phase with this prompt; returns at once (see #51). */ +export type AgentLauncher = (input: InvocationInput, prompt: string, workspace: WorkspaceRef) => InvocationHandle; +/** Trusted runner-side sources for a task. Issue text and lessons are untrusted data inside the prompt. */ +export interface ExecutionSources { + planContext(identity: PlanIdentity): PlanContext; + issue(identity: PlanIdentity): { number: number; title: string; body: string; comments: readonly string[] }; + lessons(identity: PlanIdentity): readonly string[]; + vendor(identity: PlanIdentity): 'claude' | 'codex'; +} +/** Prefix of the diagnostic for an audit safety violation; the executor moves the task to needs human on it. */ +export const SAFETY_VIOLATION = 'Safety violation:'; +export interface ExecutionResult { head: string; unchanged: boolean; inScope: string[]; outOfScope: string[] } +interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; linkSnapshot: unknown } + +/** RunnerDeps for execute attempts: fresh workspace, prompt, agent, then audit and the runner's own commit. */ +export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources): RunnerDeps { + const identityOf = (attempt: AttemptRecord): PlanIdentity => findIdentity(store, attempt); + return { + async prepare(attempt, signal) { + if (attempt.kind !== 'execute' || !attempt.item) throw new Error('Execution deps run execute attempts for one plan item.'); + const identity = identityOf(attempt), plan = store.getPlan(identity, attempt.context.planRevision); + const item = plan.items.find(entry => entry.id === attempt.item)!; + const context = sources.planContext(identity); + const baseHead = store.getSnapshot(identity, attempt.context.snapshotId).head; + const request = prepareExecution({ identity, attemptId: attempt.id, mode: 'execute', plan, itemId: item.id, + issue: sources.issue(identity), approvedLessons: sources.lessons(identity), allowedCommands: context.allowedCommands }); + const ws = await workspace.materialize(attempt, baseHead, signal); + const declaredLinks = item.files.map(file => file.path).filter(path => context.baseEntries.some(entry => entry.kind === 'symlink' && context.pathKey(entry.path) === context.pathKey(path))); + const linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredLinks, signal); + const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot }; + return { clone: ws.clone, vendor: sources.vendor(identity), approvedArgv: request.approvedArgv, private: data }; + }, + async cleanupPreparation() { /* host-side files belong to D's materialize; task storage waits for release */ }, + start(input, prepared) { const data = prepared.private as Private; return launch(input, data.prompt, data.workspace); }, + validate() { throw new Error('Execute attempts publish through finish().'); }, + async finish(attempt, _result, prepared, signal) { + const data = prepared.private as Private, identity = identityOf(attempt); + const plan = store.getPlan(identity, attempt.context.planRevision), item = plan.items.find(entry => entry.id === attempt.item)!; + const manifest = await workspace.inspectChanges(data.workspace, { baseHead: data.baseHead, linkSnapshot: data.linkSnapshot }, signal); + const outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); + if (outcome.kind === 'violation') throw new FinishFailure(`${SAFETY_VIOLATION} ${outcome.violations.join(' ')}`); + if (outcome.unchanged) return { value: { head: data.baseHead, unchanged: true, inScope: [], outOfScope: [] } satisfies ExecutionResult }; + const head = await workspace.commit(data.workspace, { + baseHead: data.baseHead, paths: [...outcome.inScope, ...outcome.outOfScope], digest: manifest.digest, + message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, + }, signal); + const snapshot = store.getSnapshot(identity, attempt.context.snapshotId); + return { + value: { head, unchanged: false, inScope: outcome.inScope, outOfScope: outcome.outOfScope } satisfies ExecutionResult, + history: { base: snapshot.base, head, entries: [{ sha: head, owner: item.id, origin: 'owned', sourceSha: null }] }, + }; + }, + async release(_attempt, prepared) { + const data = prepared.private as Private; + await workspace.release(data.workspace); + }, + }; +} +/** The plan identity that owns an attempt; attempts are stored per plan key. */ +function findIdentity(store: Store, attempt: AttemptRecord): PlanIdentity { + const key = store.attemptOwner(attempt.id); + if (!key) throw new Error('Unknown attempt.'); + const [repositoryId, taskId, planId] = JSON.parse(key) as string[]; + return { repositoryId: repositoryId!, taskId: taskId!, planId: planId! }; +} + +export type ExecutionOutcome = + | { kind: 'executed'; items: string[]; unchanged: string[] } + | { kind: 'needs amendment'; item: string; outOfScope: string[]; checkpointId: string } + | { kind: 'needs human'; item: string; reason: string } + | { kind: 'stopped'; item: string; state: string; reason: string | null }; + +/** + * Runs a task's plan items in order, one execute attempt each. Stops at the first item that does not complete cleanly: + * out-of-scope files pause the task in needs amendment with a checkpoint; a safety violation moves it to needs human. + */ +export class ItemExecutor { + #store: Store; #runner: RunnerCoordinator; #sources: ExecutionSources; + #deadlineMs: number; + constructor(store: Store, runner: RunnerCoordinator, sources: ExecutionSources, deadlineMs = 10 * 60_000) { + this.#store = store; this.#runner = runner; this.#sources = sources; this.#deadlineMs = deadlineMs; + } + async runTask(identity: PlanIdentity, options: { fromItem?: string } = {}): Promise { + const plan = this.#store.getPlan(identity); + const start = options.fromItem ? plan.items.findIndex(item => item.id === options.fromItem) : 0; + if (start < 0) throw new Error('Unknown plan item.'); + const done: string[] = [], unchanged: string[] = []; + for (const item of plan.items.slice(start)) { + const attempt = this.#runner.start(identity, { + expectedStateVersion: this.#store.getTask(identity).stateVersion, kind: 'execute', item: item.id, + expectedContext: this.#store.currentContext(identity), deadline: Date.now() + this.#deadlineMs, + }); + await this.#runner.settled(identity); + const row = this.#store.getAttempt(identity, attempt.id); + if (row.state !== 'completed') { + if (row.state === 'failed' && row.diagnostic?.startsWith(SAFETY_VIOLATION)) { + this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs human'); + return { kind: 'needs human', item: item.id, reason: row.diagnostic }; + } + return { kind: 'stopped', item: item.id, state: row.state, reason: row.diagnostic }; + } + const result = row.result as ExecutionResult; + done.push(item.id); + if (result.unchanged) unchanged.push(item.id); + if (result.outOfScope.length) { + const view = { revision: this.#store.getPlan(identity).revision, snapshotId: this.#store.getSnapshot(identity).id }; + const checkpoint = this.#store.recordCheckpoint(identity, view, { + item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries, + completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, + }); + this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs amendment'); + return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId: checkpoint.id }; + } + } + return { kind: 'executed', items: done, unchanged }; + } +} diff --git a/runner/store.ts b/runner/store.ts index d370f338..96196152 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -761,6 +761,8 @@ export class Store { */ settleAttempt(identity: PlanIdentity, id: string, settlement: Omit & { signal?: string | null; result?: unknown; diagnosticRef?: string | null; + /** Writable attempts: the runner's commit, recorded with `completed` in this same transaction. */ + history?: { base: string; head: string; entries: readonly LedgerEntry[] }; }): Classification { if (settlement.firstReason !== null && !FIRST_REASONS.includes(settlement.firstReason)) throw new GuardRefusal('Unknown stop reason.'); const key = identityKey(identity); @@ -783,6 +785,11 @@ export class Store { this.#run(`UPDATE attempts SET state=?, first_reason=?, stop_reason=?, exit_code=?, signal=?, result=?, diagnostic=?, diagnostic_ref=?, settled_at=? WHERE id=?`, outcome.state, firstReason, settlement.stopReason ?? null, settlement.exitCode, settlement.signal ?? null, result, outcome.reason, settlement.diagnosticRef ?? null, new Date().toISOString(), id); + // The guards above ran first; recording history now advances the context without invalidating this attempt. + if (outcome.state === 'completed' && settlement.history) { + const context = decode(row.context); + this.recordHistory(identity, { revision: context.planRevision, snapshotId: context.snapshotId }, settlement.history.base, settlement.history.head, settlement.history.entries); + } // A pending cancel task wins over everything, including the time limit. if (task.cancel_requested !== null && !this.#closed(task.status)) this.#closeTask(key, 'cancelled', task.cancel_requested as string); else { diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts new file mode 100644 index 00000000..3c7cad39 --- /dev/null +++ b/test/runner-execution.test.ts @@ -0,0 +1,125 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Store } from '../runner/store.ts'; +import { RunnerCoordinator } from '../runner/coordinator.ts'; +import { ItemExecutor, SAFETY_VIOLATION, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; +import type { ChangeManifest, ManifestChange } from '../core/run-audit.ts'; +import type { InvocationResult } from '../agents/contract.ts'; +import type { Plan, PlanContext } from '../core/plan.ts'; + +const oid = (n: number) => n.toString(16).padStart(40, '0'); +const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' }; +const plan: Plan = { schema_version: 1, issue: 1, revision: 1, summary: 'Two items', questions: [], items: [ + { id: 'P1', title: 'First', intent: 'Change a', files: [{ path: 'a.ts', kind: 'edit', renamed_from: null, change: 'x' }], acceptance: [{ type: 'cmd', text: 'npm test' }], depends_on: [] }, + { id: 'P2', title: 'Second', intent: 'Change b', files: [{ path: 'b.ts', kind: 'edit', renamed_from: null, change: 'y' }], acceptance: [{ type: 'check', text: 'b reads well' }], depends_on: ['P1'] }, +] }; +const context: PlanContext = { identity, issue: 1, baseEntries: [{ path: 'a.ts', kind: 'file' }, { path: 'b.ts', kind: 'file' }], pathKey: p => p, allowedCommands: [['npm', 'test']] }; +const dirs: string[] = [], cleanups: (() => Promise | void)[] = []; +afterEach(async () => { for (const c of cleanups.splice(0).reverse()) await c(); for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); +const change = (path: string, over: Partial = {}): ManifestChange => ({ path, kind: 'modify', oldType: 'file', newType: 'file', underGit: false, ...over }); +const manifest = (changes: ManifestChange[], over: Partial = {}): ChangeManifest & { digest: string } => + ({ changes, agentCommits: [], metadataChanged: false, linkTargetChanges: [], nestedGitlinkContent: [], digest: `digest-${changes.length}`, ...over }); + +function setup(options: { manifests?: Record; exit?: Record>; + commit?: (item: string) => Promise; release?: () => Promise } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); + const store = new Store(join(dir, 'state.sqlite')); + store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const log: string[] = [], commits: { item: string; baseHead: string; paths: readonly string[]; trailers: Record; digest: string; message: string }[] = []; + let next = 100; + const itemOf = (ws: WorkspaceRef) => (ws.storage as { item: string }).item; + const workspace: TaskWorkspace = { + async materialize(attempt, head) { log.push(`materialize ${attempt.item} @${head.slice(-3)}`); return { clone: { id: `c-${attempt.id}`, taskId: 'task', directory: '/tmp/x', head }, storage: { item: attempt.item, attemptId: attempt.id } }; }, + async snapshotDeclaredLinks(ws, paths) { log.push(`snapshot ${itemOf(ws)} [${paths.join(',')}]`); return { item: itemOf(ws) }; }, + async inspectChanges(ws, input) { log.push(`inspect ${itemOf(ws)} @${input.baseHead.slice(-3)}`); return options.manifests?.[itemOf(ws)] ?? manifest([change(itemOf(ws) === 'P1' ? 'a.ts' : 'b.ts')]); }, + async commit(ws, input) { + await options.commit?.(itemOf(ws)); + const head = oid(next++); commits.push({ item: itemOf(ws), baseHead: input.baseHead, paths: input.paths, trailers: { ...input.trailers }, digest: input.digest, message: input.message }); + log.push(`commit ${itemOf(ws)} -> ${head.slice(-3)}`); return head; + }, + async release(ws) { + const attemptId = (ws.storage as { attemptId: string }).attemptId; + log.push(`release ${itemOf(ws)} after ${store.getAttempt(identity, attemptId).state}`); + await options.release?.(); + }, + }; + const sources: ExecutionSources = { planContext: () => context, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; + const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = []; + const deps = executionDeps(store, workspace, (input, prompt, ws) => { + log.push(`start ${itemOf(ws)}`); prompts.push(prompt); argv.push(input.approvedArgv); + return { attemptId: input.attemptId, settled: Promise.resolve({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: 'done', stderr: '', ...options.exit?.[itemOf(ws)] }), cancel: () => undefined }; + }, sources); + const runner = new RunnerCoordinator(store, deps); + cleanups.push(async () => { await runner.close(); store.close(); }); + return { store, runner, executor: new ItemExecutor(store, runner, sources), log, commits, prompts, argv }; +} + +describe('item execution', () => { + it('runs items in order, commits each with trailers, and records owned ledger entries', async () => { + const { store, executor, log, commits, prompts, argv } = setup(); + expect(await executor.runTask(identity)).toEqual({ kind: 'executed', items: ['P1', 'P2'], unchanged: [] }); + expect(commits.map(c => [c.item, c.baseHead.slice(-3), c.trailers, c.paths, c.message])).toEqual([ + ['P1', '002', { 'Plan-Item': 'P1', 'Plan-Revision': 'r1' }, ['a.ts'], 'P1: First'], + ['P2', '064', { 'Plan-Item': 'P2', 'Plan-Revision': 'r1' }, ['b.ts'], 'P2: Second']]); + expect(store.getLedger(identity)).toEqual(expect.arrayContaining([ + { sha: oid(100), owner: 'P1', origin: 'owned', sourceSha: null }, { sha: oid(101), owner: 'P2', origin: 'owned', sourceSha: null }])); + expect(store.getSnapshot(identity).head).toBe(oid(101)); + expect(log).toEqual([ + 'materialize P1 @002', 'snapshot P1 []', 'start P1', 'inspect P1 @002', 'commit P1 -> 064', 'release P1 after completed', + 'materialize P2 @064', 'snapshot P2 []', 'start P2', 'inspect P2 @064', 'commit P2 -> 065', 'release P2 after completed']); + expect(prompts[0]).toContain(''); + expect(argv[0]).toEqual([['npm', 'test']]); + expect(argv[1]).toEqual([]); + }); + it('reports a planned-but-unchanged item without committing', async () => { + const { executor, commits } = setup({ manifests: { P1: manifest([]) } }); + expect(await executor.runTask(identity)).toEqual({ kind: 'executed', items: ['P1', 'P2'], unchanged: ['P1'] }); + expect(commits.map(c => c.item)).toEqual(['P2']); + }); + it('commits out-of-scope files with the item, records a checkpoint, and pauses in needs amendment', async () => { + const { store, executor, commits, log } = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const outcome = await executor.runTask(identity); + expect(outcome).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'] }); + expect(commits[0]!.paths).toEqual(['a.ts', 'extra.ts']); + expect(store.getCheckpoint(identity, (outcome as { checkpointId: string }).checkpointId)).toMatchObject({ item: 'P1', completedItems: ['P1'], outOfScopePaths: ['extra.ts'] }); + expect(store.getTask(identity).status).toBe('needs amendment'); + expect(log.some(line => line.includes('P2'))).toBe(false); + }); + it('stops a safety violation before any commit, fails the attempt, and moves the task to needs human', async () => { + const { store, executor, commits, log } = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) } }); + const outcome = await executor.runTask(identity); + expect(outcome).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect((outcome as { reason: string }).reason.startsWith(SAFETY_VIOLATION)).toBe(true); + expect(commits).toEqual([]); + expect(store.getTask(identity).status).toBe('needs human'); + expect(store.getLedger(identity).some(entry => entry.owner === 'P1')).toBe(false); + expect(log).toContain('release P1 after failed'); + }); + it('stops on an agent failure without inspecting or committing', async () => { + const { store, executor, log } = setup({ exit: { P1: { exitCode: 1, stderr: 'agent crashed' } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'agent crashed' }); + expect(log.some(line => line.startsWith('inspect'))).toBe(false); + expect(store.getSnapshot(identity).head).toBe(oid(2)); + }); + it('records no ledger entry when the commit is refused', async () => { + const { store, executor } = setup({ commit: async () => { throw new Error('work tree changed after the audit'); } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Invalid output: work tree changed after the audit' }); + expect(store.getLedger(identity)).toEqual([]); + }); + it('discards the commit when a stop lands while the commit runs', async () => { + let executorRunner!: RunnerCoordinator; + const { store, runner, executor } = setup({ commit: async () => { executorRunner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); } }); + executorRunner = runner; + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(store.getLedger(identity)).toEqual([]); + expect(store.getSnapshot(identity).head).toBe(oid(2)); + }); + it('holds the slot under a marker when task storage cannot be released', async () => { + const { runner, executor } = setup({ release: async () => { throw new Error('docker down'); } }); + await expect(executor.runTask(identity)).rejects.toThrow(/Needs restart/); + expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); + }); +}); From 8e57ddb564eb24b986cdae5a3a808484b80e3329 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 10:25:26 -0700 Subject: [PATCH 02/26] Pass the runner owner token into execution deps RunnerDeps now carries runnerOwner (#74), which D requires on every invocation and checks against the task storage owner. executionDeps takes the database's token and the workspace allocates storage under it. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 8 ++++++-- test/runner-execution.test.ts | 12 +++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/runner/execution.ts b/runner/execution.ts index 258a5f1c..f1e97844 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -36,10 +36,14 @@ export const SAFETY_VIOLATION = 'Safety violation:'; export interface ExecutionResult { head: string; unchanged: boolean; inScope: string[]; outOfScope: string[] } interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; linkSnapshot: unknown } -/** RunnerDeps for execute attempts: fresh workspace, prompt, agent, then audit and the runner's own commit. */ -export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources): RunnerDeps { +/** + * RunnerDeps for execute attempts: fresh workspace, prompt, agent, then audit and the runner's own commit. + * `runnerOwner` is the database's runner token (`Store.runnerOwnerToken`); `workspace` must allocate task storage under it. + */ +export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources, runnerOwner: string): RunnerDeps { const identityOf = (attempt: AttemptRecord): PlanIdentity => findIdentity(store, attempt); return { + runnerOwner, async prepare(attempt, signal) { if (attempt.kind !== 'execute' || !attempt.item) throw new Error('Execution deps run execute attempts for one plan item.'); const identity = identityOf(attempt), plan = store.getPlan(identity, attempt.context.planRevision); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 3c7cad39..a63645e5 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -10,6 +10,7 @@ import type { InvocationResult } from '../agents/contract.ts'; import type { Plan, PlanContext } from '../core/plan.ts'; const oid = (n: number) => n.toString(16).padStart(40, '0'); +const RUNNER_OWNER = '0123456789abcdef0123456789abcdef'; const identity = { repositoryId: 'repo', taskId: 'task', planId: 'plan' }; const plan: Plan = { schema_version: 1, issue: 1, revision: 1, summary: 'Two items', questions: [], items: [ { id: 'P1', title: 'First', intent: 'Change a', files: [{ path: 'a.ts', kind: 'edit', renamed_from: null, change: 'x' }], acceptance: [{ type: 'cmd', text: 'npm test' }], depends_on: [] }, @@ -47,19 +48,19 @@ function setup(options: { manifests?: Record context, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; - const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = []; + const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = [], owners: string[] = []; const deps = executionDeps(store, workspace, (input, prompt, ws) => { - log.push(`start ${itemOf(ws)}`); prompts.push(prompt); argv.push(input.approvedArgv); + log.push(`start ${itemOf(ws)}`); prompts.push(prompt); argv.push(input.approvedArgv); owners.push(input.runnerOwner); return { attemptId: input.attemptId, settled: Promise.resolve({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: 'done', stderr: '', ...options.exit?.[itemOf(ws)] }), cancel: () => undefined }; - }, sources); + }, sources, RUNNER_OWNER); const runner = new RunnerCoordinator(store, deps); cleanups.push(async () => { await runner.close(); store.close(); }); - return { store, runner, executor: new ItemExecutor(store, runner, sources), log, commits, prompts, argv }; + return { store, runner, executor: new ItemExecutor(store, runner, sources), log, commits, prompts, argv, owners }; } describe('item execution', () => { it('runs items in order, commits each with trailers, and records owned ledger entries', async () => { - const { store, executor, log, commits, prompts, argv } = setup(); + const { store, executor, log, commits, prompts, argv, owners } = setup(); expect(await executor.runTask(identity)).toEqual({ kind: 'executed', items: ['P1', 'P2'], unchanged: [] }); expect(commits.map(c => [c.item, c.baseHead.slice(-3), c.trailers, c.paths, c.message])).toEqual([ ['P1', '002', { 'Plan-Item': 'P1', 'Plan-Revision': 'r1' }, ['a.ts'], 'P1: First'], @@ -72,6 +73,7 @@ describe('item execution', () => { 'materialize P2 @064', 'snapshot P2 []', 'start P2', 'inspect P2 @064', 'commit P2 -> 065', 'release P2 after completed']); expect(prompts[0]).toContain(''); expect(argv[0]).toEqual([['npm', 'test']]); + expect(owners[0]).toBe(RUNNER_OWNER); expect(argv[1]).toEqual([]); }); it('reports a planned-but-unchanged item without committing', async () => { From fe4b427a7268c774bb8d6fe41056480574a24889 Mon Sep 17 00:00:00 2001 From: mchwang Date: Tue, 29 Sep 2026 12:54:21 -0700 Subject: [PATCH 03/26] Cover task storage release on the foreign-result and launch-failure paths After the rebase onto main, the coordinator releases task storage after the terminal write on main's foreign-result path too. Neither that path nor the launch-failure path had a test that failed without the release. Co-Authored-By: Claude Opus 5.5 --- test/runner-execution.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index a63645e5..8e0c6b21 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -24,7 +24,7 @@ const manifest = (changes: ManifestChange[], over: Partial = {}) ({ changes, agentCommits: [], metadataChanged: false, linkTargetChanges: [], nestedGitlinkContent: [], digest: `digest-${changes.length}`, ...over }); function setup(options: { manifests?: Record; exit?: Record>; - commit?: (item: string) => Promise; release?: () => Promise } = {}) { + commit?: (item: string) => Promise; release?: () => Promise; startError?: Error } = {}) { const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); const store = new Store(join(dir, 'state.sqlite')); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); @@ -50,6 +50,7 @@ function setup(options: { manifests?: Record context, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = [], owners: string[] = []; const deps = executionDeps(store, workspace, (input, prompt, ws) => { + if (options.startError) throw options.startError; log.push(`start ${itemOf(ws)}`); prompts.push(prompt); argv.push(input.approvedArgv); owners.push(input.runnerOwner); return { attemptId: input.attemptId, settled: Promise.resolve({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: 'done', stderr: '', ...options.exit?.[itemOf(ws)] }), cancel: () => undefined }; }, sources, RUNNER_OWNER); @@ -124,4 +125,16 @@ describe('item execution', () => { await expect(executor.runTask(identity)).rejects.toThrow(/Needs restart/); expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); }); + it('releases task storage after the terminal write when D settles with another attempt\'s result', async () => { + const { store, executor, log } = setup({ exit: { P1: { attemptId: '00000000-0000-4000-8000-000000000000' } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed' }); + expect(log.some(line => line.startsWith('inspect'))).toBe(false); + expect(log).toContain('release P1 after failed'); + expect(store.getSnapshot(identity).head).toBe(oid(2)); + }); + it('releases task storage after the terminal write when D\'s start call throws', async () => { + const { executor, log } = setup({ startError: new Error('docker refused') }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Launch failed: docker refused' }); + expect(log).toContain('release P1 after failed'); + }); }); From c1f46eeb6ccb022a2518b86e11a2ed25adc361fc Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:15:33 -0700 Subject: [PATCH 04/26] Fix the independent review of F2b: runner-owned findings, storage on every path, stable plan, atomic pause - Safety violations are recorded by the runner's own audit (SafetyFindings), never read back from diagnostic text, so agent stderr cannot move a task to needs human, and a violation survives a stale or stop outcome. - auditRun treats any agent commit as a violation, per #66 decision 2 (no undo path). The TaskWorkspace.commit comment now says so. - An inspection that refuses sends the task to needs human (contract, Publishing step 2); an aborted one is the stop, not a finding. - Preparation that fails after allocating task storage hands it to the coordinator (PreparationFailure), which removes it after the terminal write. - The executor stops before the next item when the plan gets a new revision, and binds a checkpoint to the revision the item ran against. - The checkpoint and the move to needs amendment commit in one transaction (Store.pauseForAmendment), through the shutdown capability; a refused pause or status change returns a stopped outcome instead of throwing. - A rename stages both paths in the runner commit. - A storage-removal failure holds the slot as 'storage-not-removed', not 'result-not-saved'. - runTask returns stopped, with the items it completed, when admission is refused or the terminal write failed. - A test covers that storage is never removed when the terminal write fails. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 3 + runner/coordinator.ts | 25 +++++-- runner/execution.ts | 130 +++++++++++++++++++++++++--------- runner/store.ts | 12 ++++ test/run-audit.test.ts | 6 +- test/runner-execution.test.ts | 104 ++++++++++++++++++++++++--- 6 files changed, 232 insertions(+), 48 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index a8903a31..6b5119c7 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -52,6 +52,9 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat const violations: string[] = []; if (!Array.isArray(manifest.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] }; if (manifest.metadataChanged) violations.push('The agent changed Git metadata under .git.'); + // Agents never commit: the metadata volume is read-only to them, so any agent commit is a violation, never undone (#66). + if (!Array.isArray(manifest.agentCommits)) violations.push('The change report has no agent commit list.'); + else if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${manifest.agentCommits.slice(0, 5).join(', ')}.`); for (const path of manifest.linkTargetChanges) violations.push(`A declared symlink target changed: ${path}.`); for (const path of manifest.nestedGitlinkContent) violations.push(`Content appeared under a gitlink: ${path}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); diff --git a/runner/coordinator.ts b/runner/coordinator.ts index 8473c1e4..99fedbdb 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -15,6 +15,14 @@ export interface PreparedAttempt { export interface HistoryRecord { readonly base: string; readonly head: string; readonly entries: readonly LedgerEntry[] } /** A finish step's failure with its own actionable diagnostic (for example a safety violation). */ export class FinishFailure extends Error {} +/** + * Preparation failed after it allocated task storage. `allocated` lets the coordinator remove that storage after the + * terminal write, as on every other path (runner-lifecycle.md, "Task storage is never removed before the terminal write"). + */ +export class PreparationFailure extends Error { + readonly allocated: PreparedAttempt; + constructor(cause: unknown, allocated: PreparedAttempt) { super(cause instanceof Error ? cause.message : String(cause), { cause }); this.allocated = allocated; } +} export interface RunnerDeps { /** * The runner token D labels every resource with (32 lowercase hex characters). `prepare` must allocate task storage @@ -56,10 +64,10 @@ export interface RunnerStatus { unresolved: { attemptId: string; reason: UnresolvedReason } | null; } /** - * Why a task's slot stays held until restart: the terminal write failed, pending -> running failed, or the host-side - * preparation files could not be removed. + * Why a task's slot stays held until restart: the terminal write failed, pending -> running failed, the host-side + * preparation files could not be removed, or task storage could not be removed after a saved terminal write. */ -export type UnresolvedReason = 'result-not-saved' | 'start-not-saved' | 'preparation-not-removed'; +export type UnresolvedReason = 'result-not-saved' | 'start-not-saved' | 'preparation-not-removed' | 'storage-not-removed'; type Group = 'writable' | 'readOnly'; interface Job { identity: PlanIdentity; key: string; group: Group; attemptId: string; attempt?: AttemptRecord; @@ -83,6 +91,7 @@ const NEEDS_RESTART: Record = { 'result-not-saved': 'Needs restart: the last result could not be saved.', 'start-not-saved': 'Needs restart: the start of the last attempt could not be saved.', 'preparation-not-removed': 'Needs restart: the last attempt\'s preparation files could not be removed.', + 'storage-not-removed': 'Needs restart: the last attempt\'s task storage could not be removed.', }; const FOREIGN_RESULT = 'The agent returned a result for a different attempt; it was not saved.'; const NOT_STARTED_UNRELEASED = 'Not started: an earlier agent\'s cleanup could not be confirmed. Restart codeboost to run it again.'; @@ -259,7 +268,10 @@ export class RunnerCoordinator { if (job.firstReason) return await this.#endBeforeLaunch(job, attempt, {}); let prepared: PreparedAttempt; try { prepared = await this.#deps.prepare(attempt, job.controller.signal); } - catch (error) { return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job, error)); } + catch (error) { + // Storage that preparation allocated before it failed is removed after the terminal write, like every other path. + return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job, error), error instanceof PreparationFailure ? error.allocated : undefined); + } if (job.firstReason || job.preparationTimedOut) return await this.#endBeforeLaunch(job, attempt, this.#preparationDetail(job), prepared); // Launch check: one synchronous turn, no await between the checks and D's start call. const now = this.#now(), row = this.#store.getAttempt(job.identity, attempt.id), task = this.#store.getTask(job.identity); @@ -376,7 +388,10 @@ export class RunnerCoordinator { async #release(job: Job, attempt: AttemptRecord, prepared: PreparedAttempt): Promise { if (!this.#deps.release) return; try { await this.#deps.release(attempt, prepared); } - catch { if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'result-not-saved' }); } + catch (error) { + console.error(`Runner job ${job.attemptId} could not remove its task storage: ${message(error)}`); + if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'storage-not-removed' }); + } } #settle(job: Job, s: { stopReason?: StopReason; exitCode: number | null; signal: string | null; valid: boolean; result?: unknown; detail?: string; history?: HistoryRecord }): Classification | undefined { try { diff --git a/runner/execution.ts b/runner/execution.ts index f1e97844..5a02de6b 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -3,8 +3,9 @@ import type { PlanContext } from '../core/plan.ts'; import type { InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; import { prepareExecution } from '../core/execution-prompt.ts'; import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; -import { FinishFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; +import { FinishFailure, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; import type { AttemptRecord, Store } from './store.ts'; +import { GuardRefusal, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; /** * F2b: per-item execution and the runner's commit step (design, "How codeboost runs a plan"; plan-format.md, "After @@ -18,7 +19,10 @@ export interface TaskWorkspace { snapshotDeclaredLinks(workspace: WorkspaceRef, paths: readonly string[], signal: AbortSignal): Promise; /** The change manifest after the agent settled, plus a digest the commit step must match. */ inspectChanges(workspace: WorkspaceRef, input: { baseHead: string; linkSnapshot: unknown }, signal: AbortSignal): Promise; - /** Undo agent commits (keeping changes), stage exactly `paths`, commit with hooks off; refuse if the tree no longer matches `digest`. */ + /** + * Commit exactly `paths` (both sides of every rename) on top of `baseHead` with hooks off; refuse if the tree no longer + * matches `digest`. Agent commits are never undone: a manifest with any is a safety violation and never gets here (#66). + */ commit(workspace: WorkspaceRef, input: { baseHead: string; paths: readonly string[]; message: string; trailers: Readonly>; digest: string }, signal: AbortSignal): Promise; release(workspace: WorkspaceRef): Promise; } @@ -31,8 +35,17 @@ export interface ExecutionSources { lessons(identity: PlanIdentity): readonly string[]; vendor(identity: PlanIdentity): 'claude' | 'codex'; } -/** Prefix of the diagnostic for an audit safety violation; the executor moves the task to needs human on it. */ +/** Prefix of the diagnostic for an audit safety violation. For people only: the executor never reads it back. */ export const SAFETY_VIOLATION = 'Safety violation:'; +/** + * Safety violations the runner's own audit found, by attempt ID. executionDeps records them and ItemExecutor takes + * them, so agent output (stderr) can never be mistaken for one, and a violation survives a later stale or stop outcome. + */ +export class SafetyFindings { + #found = new Map(); + record(attemptId: string, reason: string): void { this.#found.set(attemptId, reason); } + take(attemptId: string): string | undefined { const reason = this.#found.get(attemptId); this.#found.delete(attemptId); return reason; } +} export interface ExecutionResult { head: string; unchanged: boolean; inScope: string[]; outOfScope: string[] } interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; linkSnapshot: unknown } @@ -40,7 +53,8 @@ interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; l * RunnerDeps for execute attempts: fresh workspace, prompt, agent, then audit and the runner's own commit. * `runnerOwner` is the database's runner token (`Store.runnerOwnerToken`); `workspace` must allocate task storage under it. */ -export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources, runnerOwner: string): RunnerDeps { +export function executionDeps(store: Store, workspace: TaskWorkspace, launch: AgentLauncher, sources: ExecutionSources, runnerOwner: string, + findings: SafetyFindings): RunnerDeps { const identityOf = (attempt: AttemptRecord): PlanIdentity => findIdentity(store, attempt); return { runnerOwner, @@ -52,11 +66,15 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag const baseHead = store.getSnapshot(identity, attempt.context.snapshotId).head; const request = prepareExecution({ identity, attemptId: attempt.id, mode: 'execute', plan, itemId: item.id, issue: sources.issue(identity), approvedLessons: sources.lessons(identity), allowedCommands: context.allowedCommands }); - const ws = await workspace.materialize(attempt, baseHead, signal); + const vendor = sources.vendor(identity); const declaredLinks = item.files.map(file => file.path).filter(path => context.baseEntries.some(entry => entry.kind === 'symlink' && context.pathKey(entry.path) === context.pathKey(path))); - const linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredLinks, signal); - const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot }; - return { clone: ws.clone, vendor: sources.vendor(identity), approvedArgv: request.approvedArgv, private: data }; + const ws = await workspace.materialize(attempt, baseHead, signal); + // From here task storage exists: a failure hands it to the coordinator, which removes it after the terminal write. + const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot: undefined }; + const prepared = { clone: ws.clone, vendor, approvedArgv: request.approvedArgv, private: data }; + try { data.linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredLinks, signal); } + catch (error) { throw new PreparationFailure(error, prepared); } + return prepared; }, async cleanupPreparation() { /* host-side files belong to D's materialize; task storage waits for release */ }, start(input, prepared) { const data = prepared.private as Private; return launch(input, data.prompt, data.workspace); }, @@ -64,12 +82,26 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag async finish(attempt, _result, prepared, signal) { const data = prepared.private as Private, identity = identityOf(attempt); const plan = store.getPlan(identity, attempt.context.planRevision), item = plan.items.find(entry => entry.id === attempt.item)!; - const manifest = await workspace.inspectChanges(data.workspace, { baseHead: data.baseHead, linkSnapshot: data.linkSnapshot }, signal); + const violation = (reason: string): never => { + const text = `${SAFETY_VIOLATION} ${reason}`; + findings.record(attempt.id, text); + throw new FinishFailure(text); + }; + let manifest: ChangeManifest & { digest: string }; + try { manifest = await workspace.inspectChanges(data.workspace, { baseHead: data.baseHead, linkSnapshot: data.linkSnapshot }, signal); } + catch (error) { + // A stop aborted the inspection; that is the stop, not a finding. + if (signal.aborted) throw error; + // Contract (Publishing step 2): an inspection that refuses sends the task to needs human. + return violation(`The change inspection refused: ${error instanceof Error ? error.message : String(error)}`); + } const outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); - if (outcome.kind === 'violation') throw new FinishFailure(`${SAFETY_VIOLATION} ${outcome.violations.join(' ')}`); + if (outcome.kind === 'violation') return violation(outcome.violations.join(' ')); if (outcome.unchanged) return { value: { head: data.baseHead, unchanged: true, inScope: [], outOfScope: [] } satisfies ExecutionResult }; + // Every change is in or out of scope here; a rename stages both its old and its new path. + const paths = [...new Set(manifest.changes.flatMap(entry => [entry.path, ...(entry.oldPath ? [entry.oldPath] : [])]))]; const head = await workspace.commit(data.workspace, { - baseHead: data.baseHead, paths: [...outcome.inScope, ...outcome.outOfScope], digest: manifest.digest, + baseHead: data.baseHead, paths, digest: manifest.digest, message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); const snapshot = store.getSnapshot(identity, attempt.context.snapshotId); @@ -92,52 +124,84 @@ function findIdentity(store: Store, attempt: AttemptRecord): PlanIdentity { return { repositoryId: repositoryId!, taskId: taskId!, planId: planId! }; } +/** `completed` lists the items this run finished before it ended, so a caller never loses them. */ export type ExecutionOutcome = | { kind: 'executed'; items: string[]; unchanged: string[] } - | { kind: 'needs amendment'; item: string; outOfScope: string[]; checkpointId: string } - | { kind: 'needs human'; item: string; reason: string } - | { kind: 'stopped'; item: string; state: string; reason: string | null }; + | { kind: 'needs amendment'; item: string; outOfScope: string[]; checkpointId: string; completed: string[] } + | { kind: 'needs human'; item: string; reason: string; completed: string[] } + | { kind: 'stopped'; item: string; state: string; reason: string | null; completed: string[] }; +const refusal = (error: unknown) => error instanceof GuardRefusal || error instanceof ShuttingDownError; /** * Runs a task's plan items in order, one execute attempt each. Stops at the first item that does not complete cleanly: * out-of-scope files pause the task in needs amendment with a checkpoint; a safety violation moves it to needs human. + * The run stops too if the plan gets a new revision while it runs: every item runs against the revision it started on. */ export class ItemExecutor { - #store: Store; #runner: RunnerCoordinator; #sources: ExecutionSources; + #store: Store; #runner: RunnerCoordinator; #sources: ExecutionSources; #findings: SafetyFindings; #deadlineMs: number; - constructor(store: Store, runner: RunnerCoordinator, sources: ExecutionSources, deadlineMs = 10 * 60_000) { - this.#store = store; this.#runner = runner; this.#sources = sources; this.#deadlineMs = deadlineMs; + /** F2's status changes after an attempt settles are settlement writes: they still land after the shutdown gate closes. */ + #write: (fn: () => T) => T; + constructor(store: Store, runner: RunnerCoordinator, sources: ExecutionSources, findings: SafetyFindings, + options: { deadlineMs?: number; capability?: ShutdownCapability } = {}) { + this.#store = store; this.#runner = runner; this.#sources = sources; this.#findings = findings; + this.#deadlineMs = options.deadlineMs ?? 10 * 60_000; this.#write = settleWith(options.capability); } async runTask(identity: PlanIdentity, options: { fromItem?: string } = {}): Promise { const plan = this.#store.getPlan(identity); const start = options.fromItem ? plan.items.findIndex(item => item.id === options.fromItem) : 0; if (start < 0) throw new Error('Unknown plan item.'); const done: string[] = [], unchanged: string[] = []; + const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); for (const item of plan.items.slice(start)) { - const attempt = this.#runner.start(identity, { - expectedStateVersion: this.#store.getTask(identity).stateVersion, kind: 'execute', item: item.id, - expectedContext: this.#store.currentContext(identity), deadline: Date.now() + this.#deadlineMs, - }); + // Admission reads the context in this same turn, so it cannot notice a revision saved during an earlier item. + if (this.#store.getPlan(identity).revision !== plan.revision) + return stopped(item.id, 'not started', `The plan changed to a new revision during the run; review it before running ${item.id}.`); + let attempt: AttemptRecord; + try { + attempt = this.#runner.start(identity, { + expectedStateVersion: this.#store.getTask(identity).stateVersion, kind: 'execute', item: item.id, + expectedContext: this.#store.currentContext(identity), deadline: Date.now() + this.#deadlineMs, + }); + } catch (error) { + if (!refusal(error)) throw error; + return stopped(item.id, 'not started', error instanceof Error ? error.message : String(error)); + } await this.#runner.settled(identity); const row = this.#store.getAttempt(identity, attempt.id); - if (row.state !== 'completed') { - if (row.state === 'failed' && row.diagnostic?.startsWith(SAFETY_VIOLATION)) { - this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs human'); - return { kind: 'needs human', item: item.id, reason: row.diagnostic }; + // Only the runner's own audit records a finding; it wins over any later stale or stop outcome. + const violation = this.#findings.take(attempt.id); + if (violation) { + try { this.#write(() => this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs human')); } + catch (error) { + if (!refusal(error)) throw error; + return stopped(item.id, row.state, `${violation} The task could not be moved to needs human: ${error instanceof Error ? error.message : String(error)}`); } - return { kind: 'stopped', item: item.id, state: row.state, reason: row.diagnostic }; + return { kind: 'needs human', item: item.id, reason: violation, completed: [...done] }; + } + if (row.state !== 'completed') { + // Still pending or running: the terminal write failed and the slot is held until restart. + const unresolved = this.#runner.status(identity).unresolved; + return stopped(item.id, row.state, row.state === 'pending' || row.state === 'running' + ? `Needs restart: the result of ${item.id} could not be saved.${unresolved ? ` (${unresolved.reason})` : ''}` : row.diagnostic); } const result = row.result as ExecutionResult; done.push(item.id); if (result.unchanged) unchanged.push(item.id); if (result.outOfScope.length) { - const view = { revision: this.#store.getPlan(identity).revision, snapshotId: this.#store.getSnapshot(identity).id }; - const checkpoint = this.#store.recordCheckpoint(identity, view, { - item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries, - completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, - }); - this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs amendment'); - return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId: checkpoint.id }; + // The checkpoint names the revision the item ran against; the snapshot is the one its own commit created. + const view = { revision: row.context.planRevision, snapshotId: this.#store.getSnapshot(identity).id }; + let checkpointId: string; + try { + checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, view, { + item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries, + completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, + })).id; + } catch (error) { + if (!refusal(error) && !(error instanceof Error && /review state|executed plan prefix/i.test(error.message))) throw error; + return stopped(item.id, row.state, `${item.id} changed files outside its plan item, but the task could not pause for amendment: ${error instanceof Error ? error.message : String(error)}`); + } + return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; } } return { kind: 'executed', items: done, unchanged }; diff --git a/runner/store.ts b/runner/store.ts index 96196152..8c450efe 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -503,6 +503,18 @@ export class Store { this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); return checkpoint; }); } + /** + * F2's scope pause: the checkpoint and the move to needs amendment commit together, so a refused status change + * (a closed task, an active attempt or merge) records no checkpoint either. + */ + pauseForAmendment(identity: PlanIdentity, expected: ReviewState, evidence: Omit): Checkpoint { + const key = identityKey(identity); + return this.#transaction(() => { + const checkpoint = this.recordCheckpoint(identity, expected, evidence); + this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); + return checkpoint; + }); + } getCheckpoint(identity: PlanIdentity, id: string): Checkpoint { const row = this.#get('SELECT data FROM checkpoints WHERE key=? AND id=?', identityKey(identity), id); if (!row) throw new Error('Unknown checkpoint.'); return decode(row.data); diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index f02c8f45..de770526 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -22,10 +22,14 @@ describe('post-run audit', () => { .toMatchObject({ kind: 'commit', outOfScope: ['docs/New.md'] }); }); it('reports planned-but-unchanged, and uses the trusted path identity', () => { - expect(auditRun(item, manifest([], { agentCommits: ['abc'] }), exact)).toMatchObject({ kind: 'commit', unchanged: true }); + expect(auditRun(item, manifest([]), exact)).toMatchObject({ kind: 'commit', unchanged: true }); expect(auditRun(item, manifest([file('SRC/Retry.ts')]), folded)).toMatchObject({ inScope: ['SRC/Retry.ts'] }); expect(auditRun(item, manifest([file('SRC/Retry.ts')]), exact)).toMatchObject({ outOfScope: ['SRC/Retry.ts'] }); }); + it('treats any agent commit as a safety violation, even with no file changes (#66: never undone)', () => { + expect(auditRun(item, manifest([], { agentCommits: ['abc'] }), exact)).toEqual({ kind: 'violation', violations: ['The agent made its own commits: abc.'] }); + expect(auditRun(item, manifest([file('src/retry.ts')], { agentCommits: undefined as unknown as string[] }), exact)).toMatchObject({ kind: 'violation' }); + }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ ['metadata', manifest([file('src/retry.ts')], { metadataChanged: true })], diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 8e0c6b21..e65794e0 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -1,10 +1,12 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { RunnerCoordinator } from '../runner/coordinator.ts'; -import { ItemExecutor, SAFETY_VIOLATION, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; +import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; +import type { ShutdownCapability } from '../runner/lifecycle.ts'; import type { ChangeManifest, ManifestChange } from '../core/run-audit.ts'; import type { InvocationResult } from '../agents/contract.ts'; import type { Plan, PlanContext } from '../core/plan.ts'; @@ -24,7 +26,8 @@ const manifest = (changes: ManifestChange[], over: Partial = {}) ({ changes, agentCommits: [], metadataChanged: false, linkTargetChanges: [], nestedGitlinkContent: [], digest: `digest-${changes.length}`, ...over }); function setup(options: { manifests?: Record; exit?: Record>; - commit?: (item: string) => Promise; release?: () => Promise; startError?: Error } = {}) { + commit?: (item: string) => Promise; release?: () => Promise; startError?: Error; + inspect?: (item: string) => Promise; snapshotError?: Error; capability?: (store: Store) => ShutdownCapability; settleError?: boolean } = {}) { const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); const store = new Store(join(dir, 'state.sqlite')); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); @@ -34,8 +37,11 @@ function setup(options: { manifests?: Record (ws.storage as { item: string }).item; const workspace: TaskWorkspace = { async materialize(attempt, head) { log.push(`materialize ${attempt.item} @${head.slice(-3)}`); return { clone: { id: `c-${attempt.id}`, taskId: 'task', directory: '/tmp/x', head }, storage: { item: attempt.item, attemptId: attempt.id } }; }, - async snapshotDeclaredLinks(ws, paths) { log.push(`snapshot ${itemOf(ws)} [${paths.join(',')}]`); return { item: itemOf(ws) }; }, - async inspectChanges(ws, input) { log.push(`inspect ${itemOf(ws)} @${input.baseHead.slice(-3)}`); return options.manifests?.[itemOf(ws)] ?? manifest([change(itemOf(ws) === 'P1' ? 'a.ts' : 'b.ts')]); }, + async snapshotDeclaredLinks(ws, paths) { log.push(`snapshot ${itemOf(ws)} [${paths.join(',')}]`); if (options.snapshotError) throw options.snapshotError; return { item: itemOf(ws) }; }, + async inspectChanges(ws, input) { + log.push(`inspect ${itemOf(ws)} @${input.baseHead.slice(-3)}`); await options.inspect?.(itemOf(ws)); + return options.manifests?.[itemOf(ws)] ?? manifest([change(itemOf(ws) === 'P1' ? 'a.ts' : 'b.ts')]); + }, async commit(ws, input) { await options.commit?.(itemOf(ws)); const head = oid(next++); commits.push({ item: itemOf(ws), baseHead: input.baseHead, paths: input.paths, trailers: { ...input.trailers }, digest: input.digest, message: input.message }); @@ -49,14 +55,16 @@ function setup(options: { manifests?: Record context, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = [], owners: string[] = []; + const findings = new SafetyFindings(), capability = options.capability?.(store); + if (options.settleError) store.settleAttempt = () => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }; const deps = executionDeps(store, workspace, (input, prompt, ws) => { if (options.startError) throw options.startError; log.push(`start ${itemOf(ws)}`); prompts.push(prompt); argv.push(input.approvedArgv); owners.push(input.runnerOwner); return { attemptId: input.attemptId, settled: Promise.resolve({ attemptId: input.attemptId, context: input.context, exitCode: 0, signal: null, stdout: 'done', stderr: '', ...options.exit?.[itemOf(ws)] }), cancel: () => undefined }; - }, sources, RUNNER_OWNER); - const runner = new RunnerCoordinator(store, deps); + }, sources, RUNNER_OWNER, findings); + const runner = new RunnerCoordinator(store, deps, undefined, capability); cleanups.push(async () => { await runner.close(); store.close(); }); - return { store, runner, executor: new ItemExecutor(store, runner, sources), log, commits, prompts, argv, owners }; + return { store, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; } describe('item execution', () => { @@ -120,9 +128,16 @@ describe('item execution', () => { expect(store.getLedger(identity)).toEqual([]); expect(store.getSnapshot(identity).head).toBe(oid(2)); }); - it('holds the slot under a marker when task storage cannot be released', async () => { + it('holds the slot under a storage marker when task storage cannot be released, and stops the run with the items done', async () => { const { runner, executor } = setup({ release: async () => { throw new Error('docker down'); } }); - await expect(executor.runTask(identity)).rejects.toThrow(/Needs restart/); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', + reason: 'Needs restart: the last attempt\'s task storage could not be removed.', completed: ['P1'] }); + expect(runner.status(identity).unresolved).toMatchObject({ reason: 'storage-not-removed' }); + }); + it('never removes task storage when the terminal write fails, and reports the unsaved result', async () => { + const { runner, executor, log } = setup({ settleError: true }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'running', completed: [] }); + expect(log.some(line => line.startsWith('release'))).toBe(false); expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); }); it('releases task storage after the terminal write when D settles with another attempt\'s result', async () => { @@ -137,4 +152,75 @@ describe('item execution', () => { expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Launch failed: docker refused' }); expect(log).toContain('release P1 after failed'); }); + it('does not take the agent\'s stderr for a safety violation', async () => { + const { store, executor } = setup({ exit: { P1: { exitCode: 1, stderr: `${SAFETY_VIOLATION} fake` } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed' }); + expect(store.getTask(identity).status).toBe('running'); + }); + it('sends the task to needs human when the change inspection refuses', async () => { + const { store, executor, commits, log } = setup({ inspect: async () => { throw new Error('manifest digest mismatch'); } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', + reason: `${SAFETY_VIOLATION} The change inspection refused: manifest digest mismatch` }); + expect(commits).toEqual([]); + expect(store.getTask(identity).status).toBe('needs human'); + expect(log).toContain('release P1 after failed'); + }); + it('keeps a safety violation when the context goes stale during the audit', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + inspect: async () => { store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', 'hash-2'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('removes task storage after the terminal write when preparation fails after allocating it', async () => { + const { store, runner, executor, log } = setup({ snapshotError: new Error('declared link goes through a link') }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Preparation failed: declared link goes through a link' }); + expect(log).toEqual(['materialize P1 @002', 'snapshot P1 []', 'release P1 after failed']); + expect(runner.status(identity).unresolved).toBeNull(); + expect(store.getTask(identity).status).toBe('running'); + }); + it('stops before the next item when the plan gets a new revision during the run', async () => { + let store!: Store; + const h = setup({ release: async () => { + if (store.getPlan(identity).revision !== 1) return; + const revised = { ...plan, revision: 2, items: plan.items.map(entry => entry.id === 'P2' ? { ...entry, title: 'Second CHANGED' } : entry) }; + store.importRevision(JSON.stringify(revised), 'json', context, 1); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + expect(h.commits.map(c => c.item)).toEqual(['P1']); + }); + it('does not bind a checkpoint to a revision the item did not run against', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { + if (store.getPlan(identity).revision === 1) store.importRevision(JSON.stringify({ ...plan, revision: 2, summary: 'Revised' }), 'json', context, 1); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', completed: ['P1'] }); + expect(store.getTask(identity).status).toBe('running'); + }); + it('returns a stopped outcome, with no checkpoint, when the task closed before it could pause for amendment', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { + store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); + } }); + store = h.store; + const outcome = await h.executor.runTask(identity); + expect(outcome).toMatchObject({ kind: 'stopped', item: 'P1', completed: ['P1'] }); + expect(store.getTask(identity).status).toBe('cancelled'); + }); + it('pauses for amendment through the capability after the shutdown write gate closed', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + capability: s => s.shutdownCapability(), release: async () => { store.closeWrites(); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs amendment'); + }); + it('stages both paths of a rename in the runner commit', async () => { + const { executor, commits } = setup({ manifests: { P1: manifest([change('a.ts', { kind: 'rename', oldPath: 'old.ts' })]) } }); + await executor.runTask(identity); + expect(commits[0]!.paths).toEqual(['a.ts', 'old.ts']); + }); }); From 3b59a5bce6cbaa886d2e3cd9d014145cc645ceb0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:24:13 -0700 Subject: [PATCH 05/26] Fix round 2 of the F2b review: keep scope findings, check before the commit, fail closed on bad reports - A scope finding always pauses the task: the checkpoint is bound to the revision the item ran against and the snapshot its own commit created (Store.snapshotWithHead), not to what is current, so a revision saved during release no longer drops the pause. The round-1 fix had turned that case into a stopped outcome, which a later run skipped past. - pauseForAmendment validates against the item's own revision; only a refused status change (a closed task) returns stopped, other errors throw. - Before the runner commit, after the last await, finish re-checks the stop signal and the captured context; a change makes no commit. - A change report missing any list fails closed as a safety violation, and an audit that throws is a violation too. - The foreign-result path removes host-side preparation files after the terminal write, like every other path. - snapshotDeclaredLinks gets every path the item declares, so D checks the actual entries (an earlier item may have renamed or added links). - Tests: the pause is atomic (a refused pause leaves no checkpoint), an aborted inspection is the stop, a stop or context change during the audit makes no commit, malformed reports, foreign-result cleanup. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 6 ++-- runner/coordinator.ts | 3 +- runner/execution.ts | 30 +++++++++++------ runner/store.ts | 21 +++++++++--- test/run-audit.test.ts | 3 +- test/runner-coordinator.test.ts | 5 +-- test/runner-execution.test.ts | 57 ++++++++++++++++++++++++++++----- 7 files changed, 98 insertions(+), 27 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 6b5119c7..51c0a344 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -53,8 +53,10 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (!Array.isArray(manifest.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] }; if (manifest.metadataChanged) violations.push('The agent changed Git metadata under .git.'); // Agents never commit: the metadata volume is read-only to them, so any agent commit is a violation, never undone (#66). - if (!Array.isArray(manifest.agentCommits)) violations.push('The change report has no agent commit list.'); - else if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${manifest.agentCommits.slice(0, 5).join(', ')}.`); + // A malformed report fails closed like any other finding, never as an ordinary failed attempt. + for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) + if (!Array.isArray(manifest[field])) return { kind: 'violation', violations: [`The change report has no ${field} list.`] }; + if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${manifest.agentCommits.slice(0, 5).join(', ')}.`); for (const path of manifest.linkTargetChanges) violations.push(`A declared symlink target changed: ${path}.`); for (const path of manifest.nestedGitlinkContent) violations.push(`Content appeared under a gitlink: ${path}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); diff --git a/runner/coordinator.ts b/runner/coordinator.ts index 99fedbdb..f5e40923 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -320,8 +320,9 @@ export class RunnerCoordinator { const foreignSaved = this.#settle(job, { stopReason: 'capture-failure', exitCode: null, signal: null, valid: false, detail: job.firstReason === 'stale' ? job.staleCause : FOREIGN_RESULT }); job.decided = true; - // Task storage waits for the terminal write, as on every other path. + // Task storage and host-side preparation files wait for the terminal write, as on every other path. if (foreignSaved) await this.#release(job, attempt, prepared); + if (foreignSaved && !(await this.#removePreparation(job, attempt))) this.#holdForPreparation(job); return; } let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? bounded(result.stderr) : undefined; diff --git a/runner/execution.ts b/runner/execution.ts index 5a02de6b..cafc3ee9 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -5,7 +5,7 @@ import { prepareExecution } from '../core/execution-prompt.ts'; import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; import { FinishFailure, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; import type { AttemptRecord, Store } from './store.ts'; -import { GuardRefusal, ShuttingDownError, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { GuardRefusal, ShuttingDownError, sameContext, settleWith, type ShutdownCapability } from './lifecycle.ts'; /** * F2b: per-item execution and the runner's commit step (design, "How codeboost runs a plan"; plan-format.md, "After @@ -15,7 +15,10 @@ export interface WorkspaceRef { readonly clone: TaskClone; readonly storage: unk export interface TaskWorkspace { /** A fresh task filesystem from the recorded trusted head; never a reset of a used one. Abortable; settles only when its work stopped. */ materialize(attempt: AttemptRecord, head: string, signal: AbortSignal): Promise; - /** No-follow snapshot of every declared symlink target, taken before launch. */ + /** + * No-follow snapshot, taken before launch, of every declared path that is a symlink in this workspace. F passes every + * path the item declares: an earlier item may have renamed or added links, so only D sees the actual entries. + */ snapshotDeclaredLinks(workspace: WorkspaceRef, paths: readonly string[], signal: AbortSignal): Promise; /** The change manifest after the agent settled, plus a digest the commit step must match. */ inspectChanges(workspace: WorkspaceRef, input: { baseHead: string; linkSnapshot: unknown }, signal: AbortSignal): Promise; @@ -67,12 +70,12 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag const request = prepareExecution({ identity, attemptId: attempt.id, mode: 'execute', plan, itemId: item.id, issue: sources.issue(identity), approvedLessons: sources.lessons(identity), allowedCommands: context.allowedCommands }); const vendor = sources.vendor(identity); - const declaredLinks = item.files.map(file => file.path).filter(path => context.baseEntries.some(entry => entry.kind === 'symlink' && context.pathKey(entry.path) === context.pathKey(path))); + const declaredPaths = [...new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]))]; const ws = await workspace.materialize(attempt, baseHead, signal); // From here task storage exists: a failure hands it to the coordinator, which removes it after the terminal write. const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot: undefined }; const prepared = { clone: ws.clone, vendor, approvedArgv: request.approvedArgv, private: data }; - try { data.linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredLinks, signal); } + try { data.linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredPaths, signal); } catch (error) { throw new PreparationFailure(error, prepared); } return prepared; }, @@ -95,9 +98,14 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag // Contract (Publishing step 2): an inspection that refuses sends the task to needs human. return violation(`The change inspection refused: ${error instanceof Error ? error.message : String(error)}`); } - const outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); + let outcome: ReturnType; + try { outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); } + catch (error) { return violation(`The change report could not be audited: ${error instanceof Error ? error.message : String(error)}`); } if (outcome.kind === 'violation') return violation(outcome.violations.join(' ')); if (outcome.unchanged) return { value: { head: data.baseHead, unchanged: true, inScope: [], outOfScope: [] } satisfies ExecutionResult }; + // Last check before the commit, after the last await: a stop, shutdown or context change makes nothing. + if (signal.aborted) throw signal.reason; + if (!sameContext(attempt.context, store.currentContext(identity))) throw new FinishFailure('The plan, snapshot or assignment changed during the audit; nothing was committed.'); // Every change is in or out of scope here; a rename stages both its old and its new path. const paths = [...new Set(manifest.changes.flatMap(entry => [entry.path, ...(entry.oldPath ? [entry.oldPath] : [])]))]; const head = await workspace.commit(data.workspace, { @@ -189,16 +197,20 @@ export class ItemExecutor { done.push(item.id); if (result.unchanged) unchanged.push(item.id); if (result.outOfScope.length) { - // The checkpoint names the revision the item ran against; the snapshot is the one its own commit created. - const view = { revision: row.context.planRevision, snapshotId: this.#store.getSnapshot(identity).id }; + // The checkpoint names the revision the item ran against and the snapshot its own commit created, so a revision + // or HEAD observation saved since cannot erase the finding: the task pauses either way. + const snapshotId = this.#store.snapshotWithHead(identity, result.head); + if (!snapshotId) throw new Error(`The snapshot of ${item.id}'s commit is missing.`); + const ranAt = { revision: row.context.planRevision, snapshotId }; let checkpointId: string; try { - checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, view, { + checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, ranAt, { item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries, completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, })).id; } catch (error) { - if (!refusal(error) && !(error instanceof Error && /review state|executed plan prefix/i.test(error.message))) throw error; + // Only a refused status change (a closed task) ends here; anything else is a bug and is thrown. + if (!refusal(error)) throw error; return stopped(item.id, row.state, `${item.id} changed files outside its plan item, but the task could not pause for amendment: ${error instanceof Error ? error.message : String(error)}`); } return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; diff --git a/runner/store.ts b/runner/store.ts index 8c450efe..73123075 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -504,17 +504,30 @@ export class Store { }); } /** - * F2's scope pause: the checkpoint and the move to needs amendment commit together, so a refused status change - * (a closed task, an active attempt or merge) records no checkpoint either. + * F2's scope pause. `ranAt` is where the item actually ran: its plan revision and the snapshot its own commit + * created, not whatever is current, so a revision or HEAD observation saved since cannot erase the scope finding + * (plan-format.md, "After each run"). The checkpoint and the move to needs amendment commit together, so a refused + * status change (a closed task, an active attempt or merge) records no checkpoint either. */ - pauseForAmendment(identity: PlanIdentity, expected: ReviewState, evidence: Omit): Checkpoint { + pauseForAmendment(identity: PlanIdentity, ranAt: ReviewState, evidence: Omit): Checkpoint { const key = identityKey(identity); return this.#transaction(() => { - const checkpoint = this.recordCheckpoint(identity, expected, evidence); + if (!this.#get('SELECT 1 FROM snapshots WHERE key=? AND id=?', key, ranAt.snapshotId)) throw new Error('Unknown snapshot.'); + const ids = this.getPlan(identity, ranAt.revision).items.map(item => item.id); + if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) + throw new Error('Checkpoint must describe the executed plan prefix.'); this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); + const checkpoint = { ...evidence, revision: ranAt.revision, snapshotId: ranAt.snapshotId, id: randomUUID() }; + this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); return checkpoint; }); } + /** The latest snapshot of this plan whose head is `head` (the one a runner commit created), or null. */ + snapshotWithHead(identity: PlanIdentity, head: string): string | null { + for (const row of this.#db.prepare('SELECT id, data FROM snapshots WHERE key=? ORDER BY rowid DESC').all(identityKey(identity))) + if (decode(row.data).head === head) return row.id as string; + return null; + } getCheckpoint(identity: PlanIdentity, id: string): Checkpoint { const row = this.#get('SELECT data FROM checkpoints WHERE key=? AND id=?', identityKey(identity), id); if (!row) throw new Error('Unknown checkpoint.'); return decode(row.data); diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index de770526..be9a4b47 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -28,7 +28,8 @@ describe('post-run audit', () => { }); it('treats any agent commit as a safety violation, even with no file changes (#66: never undone)', () => { expect(auditRun(item, manifest([], { agentCommits: ['abc'] }), exact)).toEqual({ kind: 'violation', violations: ['The agent made its own commits: abc.'] }); - expect(auditRun(item, manifest([file('src/retry.ts')], { agentCommits: undefined as unknown as string[] }), exact)).toMatchObject({ kind: 'violation' }); + for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) + expect(auditRun(item, manifest([file('src/retry.ts')], { [field]: undefined as unknown as string[] }), exact)).toEqual({ kind: 'violation', violations: [`The change report has no ${field} list.`] }); }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ diff --git a/test/runner-coordinator.test.ts b/test/runner-coordinator.test.ts index 337f5c43..6214923a 100644 --- a/test/runner-coordinator.test.ts +++ b/test/runner-coordinator.test.ts @@ -508,8 +508,8 @@ describe('copilot review', () => { it.each([ ['another attempt', (input: InvocationInput) => ({ attemptId: randomUUID() })], ['another context', (input: InvocationInput) => ({ context: { ...input.context, stateVersion: input.context.stateVersion + 1 } })], - ] as const)('never validates or saves a result for %s', async (_label, foreign) => { - const { store, runner, launches, preparations, deps } = setup(); + ] as const)('never validates or saves a result for %s, and still removes its preparation files', async (_label, foreign) => { + const { store, runner, launches, preparations, deps, cleaned } = setup(); const validate = vi.spyOn(deps, 'validate'); const attempt = runner.start(A, request(store, A)); await until(() => preparations.length === 1, 'preparation'); preparations[0]!.resolve(); @@ -519,6 +519,7 @@ describe('copilot review', () => { expect(validate).not.toHaveBeenCalled(); expect(store.getAttempt(A, attempt.id)).toMatchObject({ state: 'failed', result: null, diagnostic: 'The agent returned a result for a different attempt; it was not saved.' }); + expect(cleaned()).toBe(1); expect(() => runner.start(B, request(store, B))).not.toThrow(); }); it('holds the slot when preparation files cannot be removed after D settles', async () => { diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index e65794e0..46a6b290 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -1,5 +1,6 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { DatabaseSync } from 'node:sqlite'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it } from 'vitest'; @@ -29,7 +30,7 @@ function setup(options: { manifests?: Record Promise; release?: () => Promise; startError?: Error; inspect?: (item: string) => Promise; snapshotError?: Error; capability?: (store: Store) => ShutdownCapability; settleError?: boolean } = {}) { const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); - const store = new Store(join(dir, 'state.sqlite')); + const path = join(dir, 'state.sqlite'), store = new Store(path); store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); const log: string[] = [], commits: { item: string; baseHead: string; paths: readonly string[]; trailers: Record; digest: string; message: string }[] = []; @@ -64,7 +65,7 @@ function setup(options: { manifests?: Record { await runner.close(); store.close(); }); - return { store, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; + return { store, path, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; } describe('item execution', () => { @@ -78,8 +79,8 @@ describe('item execution', () => { { sha: oid(100), owner: 'P1', origin: 'owned', sourceSha: null }, { sha: oid(101), owner: 'P2', origin: 'owned', sourceSha: null }])); expect(store.getSnapshot(identity).head).toBe(oid(101)); expect(log).toEqual([ - 'materialize P1 @002', 'snapshot P1 []', 'start P1', 'inspect P1 @002', 'commit P1 -> 064', 'release P1 after completed', - 'materialize P2 @064', 'snapshot P2 []', 'start P2', 'inspect P2 @064', 'commit P2 -> 065', 'release P2 after completed']); + 'materialize P1 @002', 'snapshot P1 [a.ts]', 'start P1', 'inspect P1 @002', 'commit P1 -> 064', 'release P1 after completed', + 'materialize P2 @064', 'snapshot P2 [b.ts]', 'start P2', 'inspect P2 @064', 'commit P2 -> 065', 'release P2 after completed']); expect(prompts[0]).toContain(''); expect(argv[0]).toEqual([['npm', 'test']]); expect(owners[0]).toBe(RUNNER_OWNER); @@ -176,7 +177,7 @@ describe('item execution', () => { it('removes task storage after the terminal write when preparation fails after allocating it', async () => { const { store, runner, executor, log } = setup({ snapshotError: new Error('declared link goes through a link') }); expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Preparation failed: declared link goes through a link' }); - expect(log).toEqual(['materialize P1 @002', 'snapshot P1 []', 'release P1 after failed']); + expect(log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after failed']); expect(runner.status(identity).unresolved).toBeNull(); expect(store.getTask(identity).status).toBe('running'); }); @@ -191,14 +192,20 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); expect(h.commits.map(c => c.item)).toEqual(['P1']); }); - it('does not bind a checkpoint to a revision the item did not run against', async () => { + it('still pauses for amendment, bound to where the item ran, when the plan changes after its attempt settled', async () => { let store!: Store; const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { if (store.getPlan(identity).revision === 1) store.importRevision(JSON.stringify({ ...plan, revision: 2, summary: 'Revised' }), 'json', context, 1); } }); store = h.store; - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', completed: ['P1'] }); - expect(store.getTask(identity).status).toBe('running'); + const outcome = await h.executor.runTask(identity); + expect(outcome).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'], completed: ['P1'] }); + expect(store.getCheckpoint(identity, (outcome as { checkpointId: string }).checkpointId)).toMatchObject({ + revision: 1, snapshotId: store.snapshotWithHead(identity, oid(100)) }); + expect(store.getTask(identity).status).toBe('needs amendment'); + // The finding is not skipped by resuming at the next item. + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started' }); + expect(h.commits.map(c => c.item)).toEqual(['P1']); }); it('returns a stopped outcome, with no checkpoint, when the task closed before it could pause for amendment', async () => { let store!: Store; @@ -209,6 +216,9 @@ describe('item execution', () => { const outcome = await h.executor.runTask(identity); expect(outcome).toMatchObject({ kind: 'stopped', item: 'P1', completed: ['P1'] }); expect(store.getTask(identity).status).toBe('cancelled'); + // The refused pause recorded no checkpoint either (one transaction). + const db = new DatabaseSync(h.path); + try { expect(db.prepare('SELECT COUNT(*) AS n FROM checkpoints').get()).toEqual({ n: 0 }); } finally { db.close(); } }); it('pauses for amendment through the capability after the shutdown write gate closed', async () => { let store!: Store; @@ -223,4 +233,35 @@ describe('item execution', () => { await executor.runTask(identity); expect(commits[0]!.paths).toEqual(['a.ts', 'old.ts']); }); + it('treats an inspection aborted by a stop as that stop, not a finding', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup({ inspect: async () => { + runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); + throw new Error('inspection aborted'); + } }); + runner = h.runner; store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(store.getTask(identity).status).toBe('running'); + expect(h.commits).toEqual([]); + }); + it('makes no commit when a stop lands during an inspection that ignores the abort', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup({ inspect: async () => { runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); } }); + runner = h.runner; store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(h.commits).toEqual([]); + }); + it('sends a malformed change report to needs human', async () => { + const { store, executor, commits } = setup({ manifests: { P1: manifest([change('a.ts')], { linkTargetChanges: undefined as unknown as string[] }) } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + expect(commits).toEqual([]); + }); + it('makes no commit when the context changes during the audit', async () => { + let store!: Store; + const h = setup({ inspect: async () => { store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', 'hash-2'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'stale' }); + expect(h.commits).toEqual([]); + }); }); From d8713ccaea4dd0f022810f6bab5acc3b69c426d2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:38:51 -0700 Subject: [PATCH 06/26] Fix round 3 of the F2b review: fail-closed manifests, a pause that cannot be skipped, whole-context checks - auditRun validates every field it reads before reading any: the metadata flag, each change's kind, entry types, underGit, rename old path, link target and traversal flag, and the total path bytes. A partial record fails closed as a violation instead of reading as clean (AGENTS.md). - A scope finding whose pause was never recorded (a failed write, the write gate, a crash) is paused at the start of the next run, before any item, so no run goes past it. A closed write gate is no longer taken for a refusal. - Between items the executor checks the whole context: the snapshot must be the one the previous item's commit created, and the assignment and referenced code unchanged, not only the plan revision. - An invalid commit ID from the workspace fails the attempt instead of breaking the terminal write. - Only the stop's own abort error counts as the stop; another inspection refusal stays a safety finding even when a stop is pending. - Pausing and escalating keep a status someone set during release: both require the task to still be running. - Tests for each, and for the audit-throws, rename-snapshot and checkpoint snapshot claims that had none. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 40 +++++++++++-- runner/execution.ts | 81 +++++++++++++++++--------- runner/store.ts | 8 +++ test/run-audit.test.ts | 17 ++++++ test/runner-execution.test.ts | 103 +++++++++++++++++++++++++++++++--- 5 files changed, 210 insertions(+), 39 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 51c0a344..75906162 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -33,6 +33,39 @@ export type AuditOutcome = | { kind: 'commit'; inScope: string[]; outOfScope: string[]; unchanged: boolean; needsAmendment: boolean }; const MAX_CHANGES = 10_000; +/** Every path in the report together; keeps the saved result (scope lists included) far below its 1 MiB limit. */ +const MAX_PATH_BYTES = 256 * 1024; +const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); +const TYPES = new Set(['file', 'symlink', 'gitlink', 'directory', 'other']); + +/** + * Every field the audit reads, checked before it reads any (AGENTS.md: partial records fail closed). A missing boolean + * or entry type must never read as "clean". Returns the first problem, or null. + */ +function malformed(manifest: ChangeManifest): string | null { + if (!manifest || typeof manifest !== 'object') return 'The change report is missing.'; + if (!Array.isArray(manifest.changes)) return 'The change report has no change list.'; + for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) + if (!Array.isArray(manifest[field]) || manifest[field].some(entry => typeof entry !== 'string')) return `The change report has no ${field} list.`; + if (typeof manifest.metadataChanged !== 'boolean') return 'The change report does not say whether Git metadata changed.'; + let bytes = 0; + for (const change of manifest.changes) { + if (!change || typeof change !== 'object' || typeof change.path !== 'string' || !change.path) return 'A change has no path.'; + if (change.oldPath !== undefined && (typeof change.oldPath !== 'string' || !change.oldPath)) return `The change at ${change.path} has an invalid old path.`; + if (!KINDS.has(change.kind)) return `The change at ${change.path} has an unknown kind.`; + if (typeof change.underGit !== 'boolean') return `The change at ${change.path} does not say whether it is under .git.`; + // add has only a new entry, delete only an old one, every other kind both. + const needsOld = change.kind !== 'add', needsNew = change.kind !== 'delete'; + if ((needsOld && !TYPES.has(change.oldType as string)) || (!needsOld && change.oldType !== undefined)) return `The change at ${change.path} has an invalid old entry type.`; + if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${change.path} has an invalid new entry type.`; + if (change.kind === 'rename' && !change.oldPath) return `The rename at ${change.path} has no old path.`; + if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${change.path} has no target.`; + if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${change.path} has an invalid traversal flag.`; + bytes += Buffer.byteLength(change.path) + (change.oldPath ? Buffer.byteLength(change.oldPath) : 0); + } + if (bytes > MAX_PATH_BYTES) return 'The change report is too large to audit.'; + return null; +} /** A stored link target must stay inside the repo, outside `.git`, without an absolute path. */ function unsafeLinkTarget(linkPath: string, target: string): string | null { @@ -50,12 +83,11 @@ function unsafeLinkTarget(linkPath: string, target: string): string | null { */ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (path: string) => string): AuditOutcome { const violations: string[] = []; - if (!Array.isArray(manifest.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] }; + if (!Array.isArray(manifest?.changes) || manifest.changes.length > MAX_CHANGES) return { kind: 'violation', violations: ['The change report is missing or too large to audit.'] }; + const problem = malformed(manifest); + if (problem) return { kind: 'violation', violations: [problem] }; if (manifest.metadataChanged) violations.push('The agent changed Git metadata under .git.'); // Agents never commit: the metadata volume is read-only to them, so any agent commit is a violation, never undone (#66). - // A malformed report fails closed like any other finding, never as an ordinary failed attempt. - for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) - if (!Array.isArray(manifest[field])) return { kind: 'violation', violations: [`The change report has no ${field} list.`] }; if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${manifest.agentCommits.slice(0, 5).join(', ')}.`); for (const path of manifest.linkTargetChanges) violations.push(`A declared symlink target changed: ${path}.`); for (const path of manifest.nestedGitlinkContent) violations.push(`Content appeared under a gitlink: ${path}.`); diff --git a/runner/execution.ts b/runner/execution.ts index cafc3ee9..4bd48691 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -93,8 +93,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag let manifest: ChangeManifest & { digest: string }; try { manifest = await workspace.inspectChanges(data.workspace, { baseHead: data.baseHead, linkSnapshot: data.linkSnapshot }, signal); } catch (error) { - // A stop aborted the inspection; that is the stop, not a finding. - if (signal.aborted) throw error; + // Only the stop's own abort error is the stop. Any other refusal is a finding, even if a stop is also pending. + if (signal.aborted && (error === signal.reason || (error instanceof Error && error.name === 'AbortError'))) throw error; // Contract (Publishing step 2): an inspection that refuses sends the task to needs human. return violation(`The change inspection refused: ${error instanceof Error ? error.message : String(error)}`); } @@ -112,6 +112,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag baseHead: data.baseHead, paths, digest: manifest.digest, message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); + // The ID goes into the ledger inside the terminal write; a malformed one must fail the attempt, not that write. + if (typeof head !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(head)) throw new FinishFailure('The workspace returned an invalid commit ID; nothing was published.'); const snapshot = store.getSnapshot(identity, attempt.context.snapshotId); return { value: { head, unchanged: false, inScope: outcome.inScope, outOfScope: outcome.outOfScope } satisfies ExecutionResult, @@ -161,15 +163,23 @@ export class ItemExecutor { if (start < 0) throw new Error('Unknown plan item.'); const done: string[] = [], unchanged: string[] = []; const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); + // A scope finding whose pause was never recorded (a failed write, the write gate, a crash) pauses now, before any item. + const owed = this.#unpausedScopeFinding(identity); + if (owed) return this.#pause(identity, owed.row, owed.result, stopped, []); + /** Where the next item must start: the context the previous item left, or the current one for the first item. */ + let expected: { snapshotId: string; assignmentId: string; referencedCodeHash: string } | null = null; for (const item of plan.items.slice(start)) { - // Admission reads the context in this same turn, so it cannot notice a revision saved during an earlier item. + // Admission reads the context in this same turn, so it cannot notice a change saved during an earlier item. + const current = this.#store.currentContext(identity); if (this.#store.getPlan(identity).revision !== plan.revision) return stopped(item.id, 'not started', `The plan changed to a new revision during the run; review it before running ${item.id}.`); + if (expected && (current.snapshotId !== expected.snapshotId || current.assignmentId !== expected.assignmentId || current.referencedCodeHash !== expected.referencedCodeHash)) + return stopped(item.id, 'not started', `The task's snapshot or assignment changed during the run; review it before running ${item.id}.`); let attempt: AttemptRecord; try { attempt = this.#runner.start(identity, { expectedStateVersion: this.#store.getTask(identity).stateVersion, kind: 'execute', item: item.id, - expectedContext: this.#store.currentContext(identity), deadline: Date.now() + this.#deadlineMs, + expectedContext: current, deadline: Date.now() + this.#deadlineMs, }); } catch (error) { if (!refusal(error)) throw error; @@ -180,10 +190,13 @@ export class ItemExecutor { // Only the runner's own audit records a finding; it wins over any later stale or stop outcome. const violation = this.#findings.take(attempt.id); if (violation) { - try { this.#write(() => this.#store.transitionTask(identity, this.#store.getTask(identity).stateVersion, 'needs human')); } + const task = this.#store.getTask(identity); + // Only the executor's own running task moves; a status someone set since is kept (no await since this read). + if (task.status !== 'running') return stopped(item.id, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); + try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } catch (error) { - if (!refusal(error)) throw error; - return stopped(item.id, row.state, `${violation} The task could not be moved to needs human: ${error instanceof Error ? error.message : String(error)}`); + if (!(error instanceof GuardRefusal)) throw error; + return stopped(item.id, row.state, `${violation} The task could not be moved to needs human: ${error.message}`); } return { kind: 'needs human', item: item.id, reason: violation, completed: [...done] }; } @@ -196,26 +209,42 @@ export class ItemExecutor { const result = row.result as ExecutionResult; done.push(item.id); if (result.unchanged) unchanged.push(item.id); - if (result.outOfScope.length) { - // The checkpoint names the revision the item ran against and the snapshot its own commit created, so a revision - // or HEAD observation saved since cannot erase the finding: the task pauses either way. - const snapshotId = this.#store.snapshotWithHead(identity, result.head); - if (!snapshotId) throw new Error(`The snapshot of ${item.id}'s commit is missing.`); - const ranAt = { revision: row.context.planRevision, snapshotId }; - let checkpointId: string; - try { - checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, ranAt, { - item: item.id, baseEntries: this.#sources.planContext(identity).baseEntries, - completedItems: plan.items.slice(0, plan.items.indexOf(item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, - })).id; - } catch (error) { - // Only a refused status change (a closed task) ends here; anything else is a bug and is thrown. - if (!refusal(error)) throw error; - return stopped(item.id, row.state, `${item.id} changed files outside its plan item, but the task could not pause for amendment: ${error instanceof Error ? error.message : String(error)}`); - } - return { kind: 'needs amendment', item: item.id, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; - } + if (result.outOfScope.length) return this.#pause(identity, row, result, stopped, done); + const snapshotId = result.unchanged ? row.context.snapshotId : this.#store.snapshotWithHead(identity, result.head); + if (!snapshotId) throw new Error(`The snapshot of ${item.id}'s commit is missing.`); + expected = { snapshotId, assignmentId: row.context.assignmentId, referencedCodeHash: row.context.referencedCodeHash }; } return { kind: 'executed', items: done, unchanged }; } + /** The latest completed execute attempt, if its out-of-scope files have no checkpoint yet (its pause was lost). */ + #unpausedScopeFinding(identity: PlanIdentity): { row: AttemptRecord; result: ExecutionResult } | null { + const row = this.#store.getAttempts(identity).filter(entry => entry.kind === 'execute' && entry.state === 'completed').at(-1); + const result = row?.result as ExecutionResult | undefined; + if (!row || !result?.outOfScope?.length) return null; + const snapshotId = this.#store.snapshotWithHead(identity, result.head); + return snapshotId && this.#store.hasCheckpointAt(identity, snapshotId) ? null : { row, result }; + } + /** + * The scope pause. The checkpoint names the revision the item ran against and the snapshot its own commit created, so + * a revision or HEAD observation saved since cannot erase the finding. Only a refused pause (the task is closed or no + * longer running) returns stopped; anything else is thrown, and the next run pauses first. + */ + #pause(identity: PlanIdentity, row: AttemptRecord, result: ExecutionResult, + stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[]): ExecutionOutcome { + const item = row.item!; + const snapshotId = this.#store.snapshotWithHead(identity, result.head); + if (!snapshotId) throw new Error(`The snapshot of ${item}'s commit is missing.`); + const items = this.#store.getPlan(identity, row.context.planRevision).items; + let checkpointId: string; + try { + checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, { revision: row.context.planRevision, snapshotId }, { + item, baseEntries: this.#sources.planContext(identity).baseEntries, + completedItems: items.slice(0, items.findIndex(entry => entry.id === item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, + })).id; + } catch (error) { + if (!(error instanceof GuardRefusal)) throw error; + return stopped(item, row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${error.message}`); + } + return { kind: 'needs amendment', item, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; + } } diff --git a/runner/store.ts b/runner/store.ts index 73123075..28d96e14 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -516,12 +516,20 @@ export class Store { const ids = this.getPlan(identity, ranAt.revision).items.map(item => item.id); if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.'); + // Only the executor's own running task pauses; a status someone set since (for example needs human) is kept. + if (this.#task(key).status !== 'running') throw new GuardRefusal('The task is no longer running, so it was not paused for amendment.'); this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); const checkpoint = { ...evidence, revision: ranAt.revision, snapshotId: ranAt.snapshotId, id: randomUUID() }; this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); return checkpoint; }); } + /** Whether a scope checkpoint was recorded at this snapshot: its item's pause is already on record. */ + hasCheckpointAt(identity: PlanIdentity, snapshotId: string): boolean { + for (const row of this.#db.prepare('SELECT data FROM checkpoints WHERE key=?').all(identityKey(identity))) + if (decode(row.data).snapshotId === snapshotId) return true; + return false; + } /** The latest snapshot of this plan whose head is `head` (the one a runner commit created), or null. */ snapshotWithHead(identity: PlanIdentity, head: string): string | null { for (const row of this.#db.prepare('SELECT id, data FROM snapshots WHERE key=? ORDER BY rowid DESC').all(identityKey(identity))) diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index be9a4b47..c7e43710 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -31,6 +31,23 @@ describe('post-run audit', () => { for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) expect(auditRun(item, manifest([file('src/retry.ts')], { [field]: undefined as unknown as string[] }), exact)).toEqual({ kind: 'violation', violations: [`The change report has no ${field} list.`] }); }); + it('fails closed on a partial record: every field the audit reads must be present and well-formed', () => { + const cases: [string, ChangeManifest, RegExp][] = [ + ['no metadata flag', manifest([file('src/retry.ts')], { metadataChanged: undefined as unknown as boolean }), /whether Git metadata changed/], + ['no underGit', manifest([{ path: 'src/retry.ts', kind: 'modify', oldType: 'file', newType: 'file' } as ManifestChange]), /under \.git/], + ['add without a new type', manifest([{ path: 'src/new.ts', kind: 'add', underGit: false } as ManifestChange]), /invalid new entry type/], + ['modify without an old type', manifest([file('src/retry.ts', { oldType: undefined })]), /invalid old entry type/], + ['unknown kind', manifest([file('src/retry.ts', { kind: 'chmod' as ManifestChange['kind'] })]), /unknown kind/], + ['rename without an old path', manifest([file('docs/New.md', { kind: 'rename' })]), /no old path/], + ['link without a target', manifest([file('link', { oldType: 'symlink', newType: 'symlink' })]), /has no target/], + ['too many path bytes', manifest(Array.from({ length: 300 }, (_, i) => file(`${'x'.repeat(1000)}${i}`))), /too large/], + ]; + for (const [label, report, reason] of cases) { + const outcome = auditRun(item, report, exact); + expect(outcome.kind, label).toBe('violation'); + expect((outcome as { violations: string[] }).violations.join(' '), label).toMatch(reason); + } + }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ ['metadata', manifest([file('src/retry.ts')], { metadataChanged: true })], diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 46a6b290..c8114e37 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { RunnerCoordinator } from '../runner/coordinator.ts'; import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; -import type { ShutdownCapability } from '../runner/lifecycle.ts'; +import { ShuttingDownError, type ShutdownCapability } from '../runner/lifecycle.ts'; import type { ChangeManifest, ManifestChange } from '../core/run-audit.ts'; import type { InvocationResult } from '../agents/contract.ts'; import type { Plan, PlanContext } from '../core/plan.ts'; @@ -28,10 +28,11 @@ const manifest = (changes: ManifestChange[], over: Partial = {}) function setup(options: { manifests?: Record; exit?: Record>; commit?: (item: string) => Promise; release?: () => Promise; startError?: Error; - inspect?: (item: string) => Promise; snapshotError?: Error; capability?: (store: Store) => ShutdownCapability; settleError?: boolean } = {}) { + inspect?: (item: string, signal: AbortSignal) => Promise; snapshotError?: Error; capability?: (store: Store) => ShutdownCapability; settleError?: boolean; + plan?: Plan; commitHead?: string; pathKeyError?: Error } = {}) { const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); const path = join(dir, 'state.sqlite'), store = new Store(path); - store.createPlan(JSON.stringify(plan), 'json', context, oid(1), oid(2)); + store.createPlan(JSON.stringify(options.plan ?? plan), 'json', context, oid(1), oid(2)); store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); const log: string[] = [], commits: { item: string; baseHead: string; paths: readonly string[]; trailers: Record; digest: string; message: string }[] = []; let next = 100; @@ -39,13 +40,13 @@ function setup(options: { manifests?: Record ${head.slice(-3)}`); return head; }, async release(ws) { @@ -54,7 +55,8 @@ function setup(options: { manifests?: Record context, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; + const auditContext: PlanContext = options.pathKeyError ? { ...context, pathKey: () => { throw options.pathKeyError; } } : context; + const sources: ExecutionSources = { planContext: () => auditContext, issue: () => ({ number: 1, title: 'Issue', body: 'Please fix', comments: [] }), lessons: () => [], vendor: () => 'claude' }; const prompts: string[] = [], argv: (readonly (readonly string[])[])[] = [], owners: string[] = []; const findings = new SafetyFindings(), capability = options.capability?.(store); if (options.settleError) store.settleAttempt = () => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }; @@ -235,15 +237,26 @@ describe('item execution', () => { }); it('treats an inspection aborted by a stop as that stop, not a finding', async () => { let runner!: RunnerCoordinator, store!: Store; - const h = setup({ inspect: async () => { + const h = setup({ inspect: async (_item, signal) => { runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); - throw new Error('inspection aborted'); + throw signal.reason; } }); runner = h.runner; store = h.store; expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); expect(store.getTask(identity).status).toBe('running'); expect(h.commits).toEqual([]); }); + it('keeps a real inspection refusal as a finding even when a stop is pending', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup({ inspect: async () => { + runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); + throw new Error('metadata digest changed'); + } }); + runner = h.runner; store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', + reason: `${SAFETY_VIOLATION} The change inspection refused: metadata digest changed` }); + expect(store.getTask(identity).status).toBe('needs human'); + }); it('makes no commit when a stop lands during an inspection that ignores the abort', async () => { let runner!: RunnerCoordinator, store!: Store; const h = setup({ inspect: async () => { runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); } }); @@ -264,4 +277,76 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'stale' }); expect(h.commits).toEqual([]); }); + it('pauses first on the next run when a scope pause was never recorded, and never runs past it', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const pause = h.store.pauseForAmendment.bind(h.store); + let fail = true; + h.store.pauseForAmendment = (...args) => { if (fail) { fail = false; throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); } return pause(...args); }; + await expect(h.executor.runTask(identity)).rejects.toThrow(/disk full/); + expect(h.store.getTask(identity).status).toBe('running'); + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'] }); + expect(h.store.getTask(identity).status).toBe('needs amendment'); + expect(h.commits.map(c => c.item)).toEqual(['P1']); + }); + it('does not take a closed write gate for a refused pause when it has no capability', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { store.closeWrites(); } }); + store = h.store; + await expect(h.executor.runTask(identity)).rejects.toBeInstanceOf(ShuttingDownError); + }); + it('stops before the next item when the assignment changes during the run', async () => { + let store!: Store; + const h = setup({ release: async () => { + if (store.getTask(identity).currentAttemptId && store.getAttempts(identity).length === 1) + store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', 'hash-2'); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + expect(h.commits.map(c => c.item)).toEqual(['P1']); + }); + it('stops before the next item, and binds a pause to the item\'s own commit, when HEAD is observed during the run', async () => { + let store!: Store; + const observe = () => { const snapshot = store.getSnapshot(identity); store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id }, snapshot.base, oid(999), []); }; + const clean = setup({ release: async () => { if (store.getAttempts(identity).length === 1) observe(); } }); + store = clean.store; + expect(await clean.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started' }); + const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => observe() }); + store = scoped.store; + const outcome = await scoped.executor.runTask(identity); + expect(outcome).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + const checkpoint = store.getCheckpoint(identity, (outcome as { checkpointId: string }).checkpointId); + expect(checkpoint.snapshotId).toBe(store.snapshotWithHead(identity, oid(100))); + expect(checkpoint.snapshotId).not.toBe(store.getSnapshot(identity).id); + }); + it('fails the attempt, instead of breaking the terminal write, when the workspace returns an invalid commit ID', async () => { + const { runner, executor, log } = setup({ commitHead: 'HEAD' }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'The workspace returned an invalid commit ID; nothing was published.' }); + expect(runner.status(identity).unresolved).toBeNull(); + expect(log).toContain('release P1 after failed'); + }); + it('keeps a status someone set during release instead of pausing or escalating over it', async () => { + let store!: Store; + const toApproval = async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); }; + const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: toApproval }); + store = scoped.store; + expect(await scoped.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs approval'); + const unsafe = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: toApproval }); + store = unsafe.store; + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs approval'); + }); + it('treats an audit that throws as a safety violation', async () => { + const { store, executor, commits } = setup({ pathKeyError: new Error('Non-ASCII case-insensitive paths require an adapter.') }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', + reason: `${SAFETY_VIOLATION} The change report could not be audited: Non-ASCII case-insensitive paths require an adapter.` }); + expect(store.getTask(identity).status).toBe('needs human'); + expect(commits).toEqual([]); + }); + it('snapshots both sides of a declared rename before launch', async () => { + const renamed: Plan = { ...plan, items: [{ ...plan.items[0]!, files: [{ path: 'c.ts', kind: 'rename', renamed_from: 'a.ts', change: 'move' }] }, plan.items[1]!] }; + const h = setup({ plan: renamed, manifests: { P1: manifest([change('c.ts', { kind: 'rename', oldPath: 'a.ts' })]) } }); + await h.executor.runTask(identity); + expect(h.log).toContain('snapshot P1 [c.ts,a.ts]'); + }); }); From ea717f680cc21a9d32c7c9c8a2699ea7f90ef1a0 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:49:51 -0700 Subject: [PATCH 07/26] Fix round 4 of the F2b review: pauses that neither stick nor get skipped - A pause owed from an earlier run is paid when the task is queued again, not only while it runs, so a task can no longer get stuck on it. - A recorded pause holds until a person approves continuing on the current revision (continuationRevision); the continued run must name the next item, not one the checkpoint already completed. - A checkpoint is found by its item and its commit's head, not by the latest snapshot, so a later snapshot with the same head cannot make an approved pause owed again. - auditRun refuses a change path of "." or "..", and an old path on any kind but rename; the path-size limit counts only the saved paths. - finish refuses a commit equal to the base for a changed item. - A slot held under a marker is reported with that marker's real cause. - Tests for each, and for the referenced-code-only context check, the AbortError stop, the per-kind entry rules and foreign-result cleanup only after a saved terminal write. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 11 ++++--- runner/coordinator.ts | 2 +- runner/execution.ts | 17 +++++++--- runner/store.ts | 26 +++++++++++---- test/run-audit.test.ts | 14 ++++++-- test/runner-coordinator.test.ts | 11 +++++++ test/runner-execution.test.ts | 58 +++++++++++++++++++++++++++++++++ 7 files changed, 120 insertions(+), 19 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 75906162..01b69aed 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -34,7 +34,7 @@ export type AuditOutcome = const MAX_CHANGES = 10_000; /** Every path in the report together; keeps the saved result (scope lists included) far below its 1 MiB limit. */ -const MAX_PATH_BYTES = 256 * 1024; +const MAX_PATH_BYTES = 480 * 1024; const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); const TYPES = new Set(['file', 'symlink', 'gitlink', 'directory', 'other']); @@ -51,17 +51,18 @@ function malformed(manifest: ChangeManifest): string | null { let bytes = 0; for (const change of manifest.changes) { if (!change || typeof change !== 'object' || typeof change.path !== 'string' || !change.path) return 'A change has no path.'; - if (change.oldPath !== undefined && (typeof change.oldPath !== 'string' || !change.oldPath)) return `The change at ${change.path} has an invalid old path.`; + // Only a rename has an old path; on any other kind it would be staged as if it were part of the change. + if (change.kind === 'rename' ? typeof change.oldPath !== 'string' || !change.oldPath : change.oldPath !== undefined) return `The change at ${change.path} has an invalid old path.`; if (!KINDS.has(change.kind)) return `The change at ${change.path} has an unknown kind.`; if (typeof change.underGit !== 'boolean') return `The change at ${change.path} does not say whether it is under .git.`; // add has only a new entry, delete only an old one, every other kind both. const needsOld = change.kind !== 'add', needsNew = change.kind !== 'delete'; if ((needsOld && !TYPES.has(change.oldType as string)) || (!needsOld && change.oldType !== undefined)) return `The change at ${change.path} has an invalid old entry type.`; if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${change.path} has an invalid new entry type.`; - if (change.kind === 'rename' && !change.oldPath) return `The rename at ${change.path} has no old path.`; if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${change.path} has no target.`; if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${change.path} has an invalid traversal flag.`; - bytes += Buffer.byteLength(change.path) + (change.oldPath ? Buffer.byteLength(change.oldPath) : 0); + // Only `path` is saved (in the result's scope lists and a checkpoint), so only it counts toward the result limit. + bytes += Buffer.byteLength(change.path) + 3; } if (bytes > MAX_PATH_BYTES) return 'The change report is too large to audit.'; return null; @@ -95,7 +96,7 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; if (change.underGit || paths.some(path => path === '.git' || path.startsWith('.git/'))) { violations.push(`The agent changed ${change.path} under .git.`); continue; } - if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || path.includes('\0'))) + if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) { violations.push(`Invalid path in the change report: ${change.path}.`); continue; } if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${change.path}.`); continue; } if (change.newType === 'symlink') { diff --git a/runner/coordinator.ts b/runner/coordinator.ts index f5e40923..77d0d1ee 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -87,7 +87,7 @@ const D_REASON: Record = { cancelled: 'cancelled', stal /** setTimeout accepts at most 2^31-1 ms; longer waits are re-armed. */ const MAX_TIMER = 2_147_483_647; const PREPARATION_TIMEOUT = 'Timed out while preparing.'; -const NEEDS_RESTART: Record = { +export const NEEDS_RESTART: Readonly> = { 'result-not-saved': 'Needs restart: the last result could not be saved.', 'start-not-saved': 'Needs restart: the start of the last attempt could not be saved.', 'preparation-not-removed': 'Needs restart: the last attempt\'s preparation files could not be removed.', diff --git a/runner/execution.ts b/runner/execution.ts index 4bd48691..7d29127c 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -3,7 +3,7 @@ import type { PlanContext } from '../core/plan.ts'; import type { InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; import { prepareExecution } from '../core/execution-prompt.ts'; import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; -import { FinishFailure, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; +import { FinishFailure, NEEDS_RESTART, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; import type { AttemptRecord, Store } from './store.ts'; import { GuardRefusal, ShuttingDownError, sameContext, settleWith, type ShutdownCapability } from './lifecycle.ts'; @@ -114,6 +114,7 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag }, signal); // The ID goes into the ledger inside the terminal write; a malformed one must fail the attempt, not that write. if (typeof head !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(head)) throw new FinishFailure('The workspace returned an invalid commit ID; nothing was published.'); + if (head === data.baseHead) throw new FinishFailure('The workspace made no new commit for a changed item; nothing was published.'); const snapshot = store.getSnapshot(identity, attempt.context.snapshotId); return { value: { head, unchanged: false, inScope: outcome.inScope, outOfScope: outcome.outOfScope } satisfies ExecutionResult, @@ -166,6 +167,15 @@ export class ItemExecutor { // A scope finding whose pause was never recorded (a failed write, the write gate, a crash) pauses now, before any item. const owed = this.#unpausedScopeFinding(identity); if (owed) return this.#pause(identity, owed.row, owed.result, stopped, []); + // A recorded pause holds until a person approves continuing on the current revision (plan-format.md: "After a + // person approves a revised plan and continuation"); the items it already completed are not run again. + const checkpoint = this.#store.latestCheckpoint(identity); + if (checkpoint) { + if (this.#store.continuationRevision(identity, checkpoint.id) !== plan.revision) + return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', `${checkpoint.item} changed files outside its plan item; approve continuing on the amended plan before running more items.`); + if (!options.fromItem || checkpoint.completedItems.includes(options.fromItem)) + return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', `Continue after ${checkpoint.item}: name the next item to run, not one that already ran.`); + } /** Where the next item must start: the context the previous item left, or the current one for the first item. */ let expected: { snapshotId: string; assignmentId: string; referencedCodeHash: string } | null = null; for (const item of plan.items.slice(start)) { @@ -204,7 +214,7 @@ export class ItemExecutor { // Still pending or running: the terminal write failed and the slot is held until restart. const unresolved = this.#runner.status(identity).unresolved; return stopped(item.id, row.state, row.state === 'pending' || row.state === 'running' - ? `Needs restart: the result of ${item.id} could not be saved.${unresolved ? ` (${unresolved.reason})` : ''}` : row.diagnostic); + ? (unresolved ? NEEDS_RESTART[unresolved.reason] : `Needs restart: the outcome of ${item.id} could not be saved.`) : row.diagnostic); } const result = row.result as ExecutionResult; done.push(item.id); @@ -221,8 +231,7 @@ export class ItemExecutor { const row = this.#store.getAttempts(identity).filter(entry => entry.kind === 'execute' && entry.state === 'completed').at(-1); const result = row?.result as ExecutionResult | undefined; if (!row || !result?.outOfScope?.length) return null; - const snapshotId = this.#store.snapshotWithHead(identity, result.head); - return snapshotId && this.#store.hasCheckpointAt(identity, snapshotId) ? null : { row, result }; + return row.item && this.#store.checkpointFor(identity, row.item, result.head) ? null : { row, result }; } /** * The scope pause. The checkpoint names the revision the item ran against and the snapshot its own commit created, so diff --git a/runner/store.ts b/runner/store.ts index 28d96e14..06ebe118 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -516,19 +516,31 @@ export class Store { const ids = this.getPlan(identity, ranAt.revision).items.map(item => item.id); if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.'); - // Only the executor's own running task pauses; a status someone set since (for example needs human) is kept. - if (this.#task(key).status !== 'running') throw new GuardRefusal('The task is no longer running, so it was not paused for amendment.'); + // Only the executor's own task pauses: running, or queued for its next run (a pause owed from an earlier run). A + // status someone set since (for example needs human or needs approval) is kept. + const status = this.#task(key).status; + if (status !== 'running' && status !== 'queued') throw new GuardRefusal(`The task is ${status}, so it was not paused for amendment.`); this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); const checkpoint = { ...evidence, revision: ranAt.revision, snapshotId: ranAt.snapshotId, id: randomUUID() }; this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); return checkpoint; }); } - /** Whether a scope checkpoint was recorded at this snapshot: its item's pause is already on record. */ - hasCheckpointAt(identity: PlanIdentity, snapshotId: string): boolean { - for (const row of this.#db.prepare('SELECT data FROM checkpoints WHERE key=?').all(identityKey(identity))) - if (decode(row.data).snapshotId === snapshotId) return true; - return false; + /** + * The scope checkpoint recorded for this item's commit, found by the item and the commit's head (not by the latest + * snapshot, which a later snapshot with the same head would shadow), or null if its pause was never recorded. + */ + checkpointFor(identity: PlanIdentity, item: string, head: string): Checkpoint | null { + for (const row of this.#db.prepare('SELECT data FROM checkpoints WHERE key=? ORDER BY rowid DESC').all(identityKey(identity))) { + const checkpoint = decode(row.data); + if (checkpoint.item === item && this.getSnapshot(identity, checkpoint.snapshotId).head === head) return checkpoint; + } + return null; + } + /** The most recent scope checkpoint of this plan, or null. */ + latestCheckpoint(identity: PlanIdentity): Checkpoint | null { + const row = this.#get('SELECT data FROM checkpoints WHERE key=? ORDER BY rowid DESC LIMIT 1', identityKey(identity)); + return row ? decode(row.data) : null; } /** The latest snapshot of this plan whose head is `head` (the one a runner commit created), or null. */ snapshotWithHead(identity: PlanIdentity, head: string): string | null { diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index c7e43710..3f8efd54 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -38,9 +38,15 @@ describe('post-run audit', () => { ['add without a new type', manifest([{ path: 'src/new.ts', kind: 'add', underGit: false } as ManifestChange]), /invalid new entry type/], ['modify without an old type', manifest([file('src/retry.ts', { oldType: undefined })]), /invalid old entry type/], ['unknown kind', manifest([file('src/retry.ts', { kind: 'chmod' as ManifestChange['kind'] })]), /unknown kind/], - ['rename without an old path', manifest([file('docs/New.md', { kind: 'rename' })]), /no old path/], + ['rename without an old path', manifest([file('docs/New.md', { kind: 'rename' })]), /invalid old path/], + ['old path on a modify', manifest([file('src/retry.ts', { oldPath: 'src/other.ts' })]), /invalid old path/], + ['non-string old path', manifest([file('docs/New.md', { kind: 'rename', oldPath: 7 as unknown as string })]), /invalid old path/], + ['delete with a new type', manifest([file('src/retry.ts', { kind: 'delete' })]), /invalid new entry type/], + ['add with an old type', manifest([file('src/new.ts', { kind: 'add' })]), /invalid old entry type/], + ['non-boolean traversal flag', manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: 'x', linkTargetTraversesLink: 'no' as unknown as boolean })]), /traversal flag/], + ['non-string list entry', manifest([file('src/retry.ts')], { nestedGitlinkContent: [{ path: 'm' }] as unknown as string[] }), /no nestedGitlinkContent list/], ['link without a target', manifest([file('link', { oldType: 'symlink', newType: 'symlink' })]), /has no target/], - ['too many path bytes', manifest(Array.from({ length: 300 }, (_, i) => file(`${'x'.repeat(1000)}${i}`))), /too large/], + ['too many path bytes', manifest(Array.from({ length: 600 }, (_, i) => file(`${'x'.repeat(1000)}${i}`))), /too large/], ]; for (const [label, report, reason] of cases) { const outcome = auditRun(item, report, exact); @@ -48,6 +54,10 @@ describe('post-run audit', () => { expect((outcome as { violations: string[] }).violations.join(' '), label).toMatch(reason); } }); + it('refuses a change path of . or ..', () => { + for (const path of ['..', '.', 'a/../..']) + expect(auditRun(item, manifest([file(path)]), exact), path).toMatchObject({ kind: 'violation' }); + }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ ['metadata', manifest([file('src/retry.ts')], { metadataChanged: true })], diff --git a/test/runner-coordinator.test.ts b/test/runner-coordinator.test.ts index 6214923a..07d715f3 100644 --- a/test/runner-coordinator.test.ts +++ b/test/runner-coordinator.test.ts @@ -522,6 +522,17 @@ describe('copilot review', () => { expect(cleaned()).toBe(1); expect(() => runner.start(B, request(store, B))).not.toThrow(); }); + it('keeps preparation files for startup recovery when a foreign result\'s terminal write fails', async () => { + const { store, runner, launches, preparations, cleaned } = setup(); + vi.spyOn(store, 'settleAttempt').mockImplementation(() => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }); + runner.start(A, request(store, A)); + await until(() => preparations.length === 1, 'preparation'); preparations[0]!.resolve(); + await until(() => launches.length === 1, 'launch'); + launches[0]!.settle({ attemptId: randomUUID() }); + await runner.settled(A); + expect(cleaned()).toBe(0); + expect(runner.status(A).unresolved).toMatchObject({ reason: 'result-not-saved' }); + }); it('holds the slot when preparation files cannot be removed after D settles', async () => { const { store, runner, launches, preparations, deps } = setup(); vi.spyOn(console, 'error').mockImplementation(() => undefined); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index c8114e37..f7fb1fed 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -349,4 +349,62 @@ describe('item execution', () => { await h.executor.runTask(identity); expect(h.log).toContain('snapshot P1 [c.ts,a.ts]'); }); + it('pays a pause owed from an earlier run when the task is queued again, instead of getting stuck', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'] }); + expect(store.getTask(identity).status).toBe('needs amendment'); + expect(h.commits.map(c => c.item)).toEqual(['P1']); + }); + it('holds a recorded pause until a person approves continuing on an amended plan, then runs only the next item', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const paused = await h.executor.runTask(identity); + expect(paused).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + const store = h.store; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/approve continuing/) }); + const amended = { ...plan, revision: 2, items: [{ ...plan.items[0]!, files: [...plan.items[0]!.files, { path: 'extra.ts', kind: 'add', renamed_from: null, change: 'z' }] }, plan.items[1]!] }; + store.importRevision(JSON.stringify(amended), 'json', { ...context, baseEntries: context.baseEntries }, 1); + store.approveContinuation(identity, (paused as { checkpointId: string }).checkpointId, { revision: 2, snapshotId: store.getSnapshot(identity).id }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/name the next item/) }); + expect(await h.executor.runTask(identity, { fromItem: 'P1' })).toMatchObject({ kind: 'stopped', state: 'not started' }); + // A later snapshot with the same head does not make the approved pause owed again. + const snapshot = store.getSnapshot(identity); + store.recordHistory(identity, { revision: 2, snapshotId: snapshot.id }, snapshot.base, snapshot.head, []); + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toEqual({ kind: 'executed', items: ['P2'], unchanged: [] }); + expect(h.commits.map(c => c.item)).toEqual(['P1', 'P2']); + }); + it('fails the attempt when the workspace makes no new commit for a changed item', async () => { + const { store, executor } = setup({ commitHead: oid(2) }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'The workspace made no new commit for a changed item; nothing was published.' }); + expect(store.getLedger(identity).some(entry => entry.owner === 'P1')).toBe(false); + }); + it('names the real cause when the start of an attempt could not be saved', async () => { + const h = setup(); + h.store.markRunning = () => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'pending', reason: 'Needs restart: the start of the last attempt could not be saved.' }); + }); + it('stops before the next item when only the referenced code changes during the run', async () => { + let store!: Store; + const h = setup({ release: async () => { + if (store.getAttempts(identity).length !== 1) return; + store.setAssignment(identity, store.getTask(identity).stateVersion, store.currentContext(identity).assignmentId, 'new-code-hash'); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + }); + it('treats an AbortError from the inspection as the stop', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup({ inspect: async () => { + runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); + throw Object.assign(new Error('The operation was aborted'), { name: 'AbortError' }); + } }); + runner = h.runner; store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(store.getTask(identity).status).toBe('running'); + }); }); From a6b04e53e3a5d746fd58e172b7180aabeabe6798 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 09:59:53 -0700 Subject: [PATCH 08/26] Fix round 5 of the F2b review: fail closed after a scope pause, canonical paths, JSON-sized limit - A task with a scope checkpoint runs no further items. Continuing after a scope pause needs its own design (reconcile the prefix, validate the rest from the checkpoint, consume the approval), tracked in #88; round 4's partial approval gate could block an approved continuation forever or skip items. Before round 4 the executor ran past the pause. - A checkpoint is found by its commit's head alone; two items cannot share the head of a scope commit. - This run's own pause and escalation keep any status someone set during release, queued included; only a pause owed from an earlier run is paid from queued. - auditRun refuses a path not in canonical form (./, a/../, //, a trailing /) and a .git part of any case at any depth. - The path-size limit measures each saved path as the JSON that stores it. - Tests for each, and for keeping task storage when a foreign result's terminal write fails. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 10 +++++++--- runner/execution.ts | 21 +++++++++------------ runner/store.ts | 16 ++++++++-------- test/run-audit.test.ts | 12 ++++++++++-- test/runner-execution.test.ts | 35 +++++++++++++++++++++++++++-------- 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 01b69aed..2990153b 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -61,8 +61,8 @@ function malformed(manifest: ChangeManifest): string | null { if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${change.path} has an invalid new entry type.`; if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${change.path} has no target.`; if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${change.path} has an invalid traversal flag.`; - // Only `path` is saved (in the result's scope lists and a checkpoint), so only it counts toward the result limit. - bytes += Buffer.byteLength(change.path) + 3; + // Only `path` is saved (in the result's scope lists and a checkpoint), so only it counts, as the JSON that stores it. + bytes += Buffer.byteLength(JSON.stringify(change.path)) + 1; } if (bytes > MAX_PATH_BYTES) return 'The change report is too large to audit.'; return null; @@ -95,7 +95,11 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; - if (change.underGit || paths.some(path => path === '.git' || path.startsWith('.git/'))) { violations.push(`The agent changed ${change.path} under .git.`); continue; } + // A path must be in canonical form: another spelling (./, a/../, //, a trailing /) could reach .git or hide a match. + if (paths.some(path => path !== posix.normalize(path) || path.endsWith('/') || path.startsWith('./'))) + { violations.push(`Path not in canonical form in the change report: ${change.path}.`); continue; } + // Git refuses a .git part of any case at any depth, so the audit does too. + if (change.underGit || paths.some(path => path.split('/').some((part: string) => part.toLowerCase() === '.git'))) { violations.push(`The agent changed ${change.path} under .git.`); continue; } if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) { violations.push(`Invalid path in the change report: ${change.path}.`); continue; } if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${change.path}.`); continue; } diff --git a/runner/execution.ts b/runner/execution.ts index 7d29127c..65a16340 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -166,16 +166,13 @@ export class ItemExecutor { const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); // A scope finding whose pause was never recorded (a failed write, the write gate, a crash) pauses now, before any item. const owed = this.#unpausedScopeFinding(identity); - if (owed) return this.#pause(identity, owed.row, owed.result, stopped, []); - // A recorded pause holds until a person approves continuing on the current revision (plan-format.md: "After a - // person approves a revised plan and continuation"); the items it already completed are not run again. + if (owed) return this.#pause(identity, owed.row, owed.result, stopped, [], true); + // Continuing after a scope pause (plan-format.md: reconcile the executed prefix with the audited head, validate the + // remaining items from that checkpoint) is not built yet (#88), so a paused task runs no further items: fail closed. const checkpoint = this.#store.latestCheckpoint(identity); - if (checkpoint) { - if (this.#store.continuationRevision(identity, checkpoint.id) !== plan.revision) - return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', `${checkpoint.item} changed files outside its plan item; approve continuing on the amended plan before running more items.`); - if (!options.fromItem || checkpoint.completedItems.includes(options.fromItem)) - return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', `Continue after ${checkpoint.item}: name the next item to run, not one that already ran.`); - } + if (checkpoint) + return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', + `${checkpoint.item} changed files outside its plan item. Continuing after a scope pause is not supported yet (#88), so this task runs no further items.`); /** Where the next item must start: the context the previous item left, or the current one for the first item. */ let expected: { snapshotId: string; assignmentId: string; referencedCodeHash: string } | null = null; for (const item of plan.items.slice(start)) { @@ -231,7 +228,7 @@ export class ItemExecutor { const row = this.#store.getAttempts(identity).filter(entry => entry.kind === 'execute' && entry.state === 'completed').at(-1); const result = row?.result as ExecutionResult | undefined; if (!row || !result?.outOfScope?.length) return null; - return row.item && this.#store.checkpointFor(identity, row.item, result.head) ? null : { row, result }; + return this.#store.checkpointAtHead(identity, result.head) ? null : { row, result }; } /** * The scope pause. The checkpoint names the revision the item ran against and the snapshot its own commit created, so @@ -239,7 +236,7 @@ export class ItemExecutor { * longer running) returns stopped; anything else is thrown, and the next run pauses first. */ #pause(identity: PlanIdentity, row: AttemptRecord, result: ExecutionResult, - stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[]): ExecutionOutcome { + stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[], owed = false): ExecutionOutcome { const item = row.item!; const snapshotId = this.#store.snapshotWithHead(identity, result.head); if (!snapshotId) throw new Error(`The snapshot of ${item}'s commit is missing.`); @@ -249,7 +246,7 @@ export class ItemExecutor { checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, { revision: row.context.planRevision, snapshotId }, { item, baseEntries: this.#sources.planContext(identity).baseEntries, completedItems: items.slice(0, items.findIndex(entry => entry.id === item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, - })).id; + }, { owed })).id; } catch (error) { if (!(error instanceof GuardRefusal)) throw error; return stopped(item, row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${error.message}`); diff --git a/runner/store.ts b/runner/store.ts index 06ebe118..b9f099d5 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -509,17 +509,17 @@ export class Store { * (plan-format.md, "After each run"). The checkpoint and the move to needs amendment commit together, so a refused * status change (a closed task, an active attempt or merge) records no checkpoint either. */ - pauseForAmendment(identity: PlanIdentity, ranAt: ReviewState, evidence: Omit): Checkpoint { + pauseForAmendment(identity: PlanIdentity, ranAt: ReviewState, evidence: Omit, options: { owed?: boolean } = {}): Checkpoint { const key = identityKey(identity); return this.#transaction(() => { if (!this.#get('SELECT 1 FROM snapshots WHERE key=? AND id=?', key, ranAt.snapshotId)) throw new Error('Unknown snapshot.'); const ids = this.getPlan(identity, ranAt.revision).items.map(item => item.id); if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.'); - // Only the executor's own task pauses: running, or queued for its next run (a pause owed from an earlier run). A - // status someone set since (for example needs human or needs approval) is kept. + // Only the executor's own task pauses. In the run that found it, that is a running task: any status someone set + // since (queued included) is kept. A pause owed from an earlier run is also paid from queued, the next run's start. const status = this.#task(key).status; - if (status !== 'running' && status !== 'queued') throw new GuardRefusal(`The task is ${status}, so it was not paused for amendment.`); + if (status !== 'running' && !(options.owed && status === 'queued')) throw new GuardRefusal(`The task is ${status}, so it was not paused for amendment.`); this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); const checkpoint = { ...evidence, revision: ranAt.revision, snapshotId: ranAt.snapshotId, id: randomUUID() }; this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); @@ -527,13 +527,13 @@ export class Store { }); } /** - * The scope checkpoint recorded for this item's commit, found by the item and the commit's head (not by the latest - * snapshot, which a later snapshot with the same head would shadow), or null if its pause was never recorded. + * The scope checkpoint recorded for a runner commit, found by the commit's head (not by the latest snapshot, which a + * later snapshot with the same head would shadow), or null if its pause was never recorded. */ - checkpointFor(identity: PlanIdentity, item: string, head: string): Checkpoint | null { + checkpointAtHead(identity: PlanIdentity, head: string): Checkpoint | null { for (const row of this.#db.prepare('SELECT data FROM checkpoints WHERE key=? ORDER BY rowid DESC').all(identityKey(identity))) { const checkpoint = decode(row.data); - if (checkpoint.item === item && this.getSnapshot(identity, checkpoint.snapshotId).head === head) return checkpoint; + if (this.getSnapshot(identity, checkpoint.snapshotId).head === head) return checkpoint; } return null; } diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 3f8efd54..adb42937 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -54,9 +54,17 @@ describe('post-run audit', () => { expect((outcome as { violations: string[] }).violations.join(' '), label).toMatch(reason); } }); - it('refuses a change path of . or ..', () => { - for (const path of ['..', '.', 'a/../..']) + it('refuses a change path of . or .., any non-canonical spelling, and .git in any case at any depth', () => { + for (const path of ['..', '.', 'a/../..', 'x/../.git/hooks/pre-commit', './.git/config', './a.ts', 'a//b', 'dir/', 'sub/.git/config', '.GIT/config', 'src/.Git']) expect(auditRun(item, manifest([file(path)]), exact), path).toMatchObject({ kind: 'violation' }); + expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: 'x/../.git/config' })]), exact)).toMatchObject({ kind: 'violation' }); + }); + it('limits the saved paths as JSON, and does not count a rename\'s old path', () => { + // 100 paths of 4,000 control characters are under the raw byte cap but about 2.4 MB as JSON. + expect(auditRun(item, manifest(Array.from({ length: 100 }, (_, i) => file(`${'\u0001'.repeat(4000)}${i}`, { kind: 'add', oldType: undefined }))), exact)) + .toEqual({ kind: 'violation', violations: ['The change report is too large to audit.'] }); + // A long old path is not saved, so it does not count. + expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: `docs/${'o'.repeat(600_000)}.md` })]), exact)).toMatchObject({ kind: 'commit' }); }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index f7fb1fed..0615e06a 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -360,23 +360,24 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('needs amendment'); expect(h.commits.map(c => c.item)).toEqual(['P1']); }); - it('holds a recorded pause until a person approves continuing on an amended plan, then runs only the next item', async () => { + it('runs no further items once a task has a scope checkpoint, even after an approved continuation (not supported yet)', async () => { const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); const paused = await h.executor.runTask(identity); expect(paused).toMatchObject({ kind: 'needs amendment', item: 'P1' }); const store = h.store; store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); - expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/approve continuing/) }); + const refused = { kind: 'stopped', state: 'not started', reason: expect.stringMatching(/Continuing after a scope pause is not supported yet/) }; + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject(refused); const amended = { ...plan, revision: 2, items: [{ ...plan.items[0]!, files: [...plan.items[0]!.files, { path: 'extra.ts', kind: 'add', renamed_from: null, change: 'z' }] }, plan.items[1]!] }; - store.importRevision(JSON.stringify(amended), 'json', { ...context, baseEntries: context.baseEntries }, 1); + store.importRevision(JSON.stringify(amended), 'json', context, 1); store.approveContinuation(identity, (paused as { checkpointId: string }).checkpointId, { revision: 2, snapshotId: store.getSnapshot(identity).id }); - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/name the next item/) }); - expect(await h.executor.runTask(identity, { fromItem: 'P1' })).toMatchObject({ kind: 'stopped', state: 'not started' }); - // A later snapshot with the same head does not make the approved pause owed again. + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject(refused); + // A later snapshot with the same head does not make the recorded pause owed again. const snapshot = store.getSnapshot(identity); store.recordHistory(identity, { revision: 2, snapshotId: snapshot.id }, snapshot.base, snapshot.head, []); - expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toEqual({ kind: 'executed', items: ['P2'], unchanged: [] }); - expect(h.commits.map(c => c.item)).toEqual(['P1', 'P2']); + expect(await h.executor.runTask(identity, { fromItem: 'P2' })).toMatchObject(refused); + expect(store.getTask(identity).status).toBe('queued'); + expect(h.commits.map(c => c.item)).toEqual(['P1']); }); it('fails the attempt when the workspace makes no new commit for a changed item', async () => { const { store, executor } = setup({ commitHead: oid(2) }); @@ -407,4 +408,22 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); expect(store.getTask(identity).status).toBe('running'); }); + it('keeps a queued status someone set during release: this run neither pauses nor escalates over it', async () => { + let store!: Store; + const toQueued = async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); }; + const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: toQueued }); + store = scoped.store; + expect(await scoped.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + expect(store.getTask(identity).status).toBe('queued'); + const unsafe = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: toQueued }); + store = unsafe.store; + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/is queued, so it was not moved to needs human/) }); + expect(store.getTask(identity).status).toBe('queued'); + }); + it('keeps task storage when a foreign result\'s terminal write fails', async () => { + const { runner, executor, log } = setup({ settleError: true, exit: { P1: { attemptId: '00000000-0000-4000-8000-000000000000' } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'running' }); + expect(log.some(line => line.startsWith('release'))).toBe(false); + expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); + }); }); From c0dfa90bfbab0a3cb5bbdd3744e0ab0579000b56 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:10:50 -0700 Subject: [PATCH 09/26] Fix round 6 of the F2b review: escalate violations over any open status, stricter audit findings - A safety violation moves the task to needs human over any status someone set during release (queued or a human gate), since a run from there would launch the item again; only a closed task is left as it is. - A declared link retargeted into .git in any case or at any depth is refused, as paths already are. - A rename from an undeclared path records that source path as out of scope, not only the declared destination. - A change report that lists one path twice fails closed. - Agent-controlled paths in findings are quoted and lists cut short, and the finding text is bounded (AGENTS.md). - Tests for each, for task storage removed after the terminal write when a stop, a stale context or a cancel task lands during preparation, and for pauseForAmendment's plan-prefix check. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 54 ++++++++++++++---------- runner/execution.ts | 13 +++--- test/run-audit.test.ts | 19 ++++++++- test/runner-execution.test.ts | 78 ++++++++++++++++++++++++++++++----- 4 files changed, 124 insertions(+), 40 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 2990153b..386194ca 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -36,6 +36,9 @@ const MAX_CHANGES = 10_000; /** Every path in the report together; keeps the saved result (scope lists included) far below its 1 MiB limit. */ const MAX_PATH_BYTES = 480 * 1024; const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); +/** Agent-controlled text in a finding is quoted (AGENTS.md), and each list is cut short, so a reason stays readable. */ +const q = (text: string) => JSON.stringify(text.length > 300 ? `${text.slice(0, 300)}…` : text); +const list = (items: readonly string[]) => items.slice(0, 5).map(q).join(', ') + (items.length > 5 ? ` and ${items.length - 5} more` : ''); const TYPES = new Set(['file', 'symlink', 'gitlink', 'directory', 'other']); /** @@ -49,18 +52,24 @@ function malformed(manifest: ChangeManifest): string | null { if (!Array.isArray(manifest[field]) || manifest[field].some(entry => typeof entry !== 'string')) return `The change report has no ${field} list.`; if (typeof manifest.metadataChanged !== 'boolean') return 'The change report does not say whether Git metadata changed.'; let bytes = 0; + const seen = new Set(); for (const change of manifest.changes) { if (!change || typeof change !== 'object' || typeof change.path !== 'string' || !change.path) return 'A change has no path.'; // Only a rename has an old path; on any other kind it would be staged as if it were part of the change. - if (change.kind === 'rename' ? typeof change.oldPath !== 'string' || !change.oldPath : change.oldPath !== undefined) return `The change at ${change.path} has an invalid old path.`; - if (!KINDS.has(change.kind)) return `The change at ${change.path} has an unknown kind.`; - if (typeof change.underGit !== 'boolean') return `The change at ${change.path} does not say whether it is under .git.`; + if (change.kind === 'rename' ? typeof change.oldPath !== 'string' || !change.oldPath : change.oldPath !== undefined) return `The change at ${q(change.path)} has an invalid old path.`; + if (!KINDS.has(change.kind)) return `The change at ${q(change.path)} has an unknown kind.`; + if (typeof change.underGit !== 'boolean') return `The change at ${q(change.path)} does not say whether it is under .git.`; // add has only a new entry, delete only an old one, every other kind both. const needsOld = change.kind !== 'add', needsNew = change.kind !== 'delete'; - if ((needsOld && !TYPES.has(change.oldType as string)) || (!needsOld && change.oldType !== undefined)) return `The change at ${change.path} has an invalid old entry type.`; - if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${change.path} has an invalid new entry type.`; - if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${change.path} has no target.`; - if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${change.path} has an invalid traversal flag.`; + if ((needsOld && !TYPES.has(change.oldType as string)) || (!needsOld && change.oldType !== undefined)) return `The change at ${q(change.path)} has an invalid old entry type.`; + if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${q(change.path)} has an invalid new entry type.`; + if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${q(change.path)} has no target.`; + if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${q(change.path)} has an invalid traversal flag.`; + // Each path appears once: two entries for one path (a modify and a delete, say) contradict each other. + for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) { + if (seen.has(path)) return `The change report lists ${q(path)} more than once.`; + seen.add(path); + } // Only `path` is saved (in the result's scope lists and a checkpoint), so only it counts, as the JSON that stores it. bytes += Buffer.byteLength(JSON.stringify(change.path)) + 1; } @@ -74,7 +83,8 @@ function unsafeLinkTarget(linkPath: string, target: string): string | null { if (target.startsWith('/')) return 'absolute target'; const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)); if (resolved === '..' || resolved.startsWith('../')) return 'target leaves the repository'; - if (resolved === '.git' || resolved.startsWith('.git/')) return 'target enters .git'; + // As for paths: Git's metadata is `.git` in any case, at any depth. + if (resolved.split('/').some((part: string) => part.toLowerCase() === '.git')) return 'target enters .git'; return null; } @@ -89,34 +99,36 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (problem) return { kind: 'violation', violations: [problem] }; if (manifest.metadataChanged) violations.push('The agent changed Git metadata under .git.'); // Agents never commit: the metadata volume is read-only to them, so any agent commit is a violation, never undone (#66). - if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${manifest.agentCommits.slice(0, 5).join(', ')}.`); - for (const path of manifest.linkTargetChanges) violations.push(`A declared symlink target changed: ${path}.`); - for (const path of manifest.nestedGitlinkContent) violations.push(`Content appeared under a gitlink: ${path}.`); + if (manifest.agentCommits.length) violations.push(`The agent made its own commits: ${list(manifest.agentCommits)}.`); + if (manifest.linkTargetChanges.length) violations.push(`A declared symlink target changed: ${list(manifest.linkTargetChanges)}.`); + if (manifest.nestedGitlinkContent.length) violations.push(`Content appeared under a gitlink: ${list(manifest.nestedGitlinkContent)}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; // A path must be in canonical form: another spelling (./, a/../, //, a trailing /) could reach .git or hide a match. if (paths.some(path => path !== posix.normalize(path) || path.endsWith('/') || path.startsWith('./'))) - { violations.push(`Path not in canonical form in the change report: ${change.path}.`); continue; } + { violations.push(`Path not in canonical form in the change report: ${q(change.path)}.`); continue; } // Git refuses a .git part of any case at any depth, so the audit does too. - if (change.underGit || paths.some(path => path.split('/').some((part: string) => part.toLowerCase() === '.git'))) { violations.push(`The agent changed ${change.path} under .git.`); continue; } + if (change.underGit || paths.some(path => path.split('/').some((part: string) => part.toLowerCase() === '.git'))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; } if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) - { violations.push(`Invalid path in the change report: ${change.path}.`); continue; } - if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${change.path}.`); continue; } + { violations.push(`Invalid path in the change report: ${q(change.path)}.`); continue; } + if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${q(change.path)}.`); continue; } if (change.newType === 'symlink') { - if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${change.path}.`); continue; } - if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${change.path}.`); continue; } + if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${q(change.path)}.`); continue; } + if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${q(change.path)}.`); continue; } const unsafe = unsafeLinkTarget(change.path, change.newLinkTarget ?? ''); - if (unsafe) { violations.push(`Unsafe symlink target at ${change.path}: ${unsafe}.`); continue; } - if (change.linkTargetTraversesLink !== false) { violations.push(`The symlink target at ${change.path} traverses another link, or was not checked.`); continue; } + if (unsafe) { violations.push(`Unsafe symlink target at ${q(change.path)}: ${unsafe}.`); continue; } + if (change.linkTargetTraversesLink !== false) { violations.push(`The symlink target at ${q(change.path)} traverses another link, or was not checked.`); continue; } } - for (const type of [change.oldType, change.newType]) if (type === 'directory' || type === 'other') violations.push(`Unexpected ${type} entry: ${change.path}.`); + for (const type of [change.oldType, change.newType]) if (type === 'directory' || type === 'other') violations.push(`Unexpected ${type} entry: ${q(change.path)}.`); } if (violations.length) return { kind: 'violation', violations }; const inScope: string[] = [], outOfScope: string[] = []; for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; - (paths.every(path => declared.has(pathKey(path))) ? inScope : outOfScope).push(change.path); + const undeclared = paths.filter(path => !declared.has(pathKey(path))); + // Each undeclared path is the scope finding itself, a rename's source included: never only the declared other side. + if (undeclared.length) outOfScope.push(...undeclared); else inScope.push(change.path); } return { kind: 'commit', inScope, outOfScope, unchanged: manifest.changes.length === 0, needsAmendment: outOfScope.length > 0 }; } diff --git a/runner/execution.ts b/runner/execution.ts index 65a16340..85fbb465 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -5,7 +5,7 @@ import { prepareExecution } from '../core/execution-prompt.ts'; import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; import { FinishFailure, NEEDS_RESTART, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; import type { AttemptRecord, Store } from './store.ts'; -import { GuardRefusal, ShuttingDownError, sameContext, settleWith, type ShutdownCapability } from './lifecycle.ts'; +import { CLOSED_STATUSES, GuardRefusal, ShuttingDownError, bounded, sameContext, settleWith, type ShutdownCapability } from './lifecycle.ts'; /** * F2b: per-item execution and the runner's commit step (design, "How codeboost runs a plan"; plan-format.md, "After @@ -86,7 +86,7 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag const data = prepared.private as Private, identity = identityOf(attempt); const plan = store.getPlan(identity, attempt.context.planRevision), item = plan.items.find(entry => entry.id === attempt.item)!; const violation = (reason: string): never => { - const text = `${SAFETY_VIOLATION} ${reason}`; + const text = bounded(`${SAFETY_VIOLATION} ${reason}`); findings.record(attempt.id, text); throw new FinishFailure(text); }; @@ -96,11 +96,11 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag // Only the stop's own abort error is the stop. Any other refusal is a finding, even if a stop is also pending. if (signal.aborted && (error === signal.reason || (error instanceof Error && error.name === 'AbortError'))) throw error; // Contract (Publishing step 2): an inspection that refuses sends the task to needs human. - return violation(`The change inspection refused: ${error instanceof Error ? error.message : String(error)}`); + return violation(`The change inspection refused: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } let outcome: ReturnType; try { outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); } - catch (error) { return violation(`The change report could not be audited: ${error instanceof Error ? error.message : String(error)}`); } + catch (error) { return violation(`The change report could not be audited: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } if (outcome.kind === 'violation') return violation(outcome.violations.join(' ')); if (outcome.unchanged) return { value: { head: data.baseHead, unchanged: true, inScope: [], outOfScope: [] } satisfies ExecutionResult }; // Last check before the commit, after the last await: a stop, shutdown or context change makes nothing. @@ -198,8 +198,9 @@ export class ItemExecutor { const violation = this.#findings.take(attempt.id); if (violation) { const task = this.#store.getTask(identity); - // Only the executor's own running task moves; a status someone set since is kept (no await since this read). - if (task.status !== 'running') return stopped(item.id, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); + // A safety violation goes to needs human over any status someone set since (queued, a human gate), because a + // run from that status would launch the item again; only a closed task is left as it is (no await since this read). + if (CLOSED_STATUSES.includes(task.status)) return stopped(item.id, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } catch (error) { if (!(error instanceof GuardRefusal)) throw error; diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index adb42937..d331875b 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -18,8 +18,9 @@ describe('post-run audit', () => { .toEqual({ kind: 'commit', inScope: ['src/retry.ts', 'docs/New.md'], outOfScope: [], unchanged: false, needsAmendment: false }); expect(auditRun(item, manifest([file('src/retry.ts'), file('src/extra.ts', { kind: 'add', oldType: undefined })]), exact)) .toMatchObject({ kind: 'commit', outOfScope: ['src/extra.ts'], needsAmendment: true }); + // The undeclared source is the finding, not the declared destination. expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: 'docs/unlisted.md' })]), exact)) - .toMatchObject({ kind: 'commit', outOfScope: ['docs/New.md'] }); + .toMatchObject({ kind: 'commit', inScope: [], outOfScope: ['docs/unlisted.md'] }); }); it('reports planned-but-unchanged, and uses the trusted path identity', () => { expect(auditRun(item, manifest([]), exact)).toMatchObject({ kind: 'commit', unchanged: true }); @@ -27,7 +28,7 @@ describe('post-run audit', () => { expect(auditRun(item, manifest([file('SRC/Retry.ts')]), exact)).toMatchObject({ outOfScope: ['SRC/Retry.ts'] }); }); it('treats any agent commit as a safety violation, even with no file changes (#66: never undone)', () => { - expect(auditRun(item, manifest([], { agentCommits: ['abc'] }), exact)).toEqual({ kind: 'violation', violations: ['The agent made its own commits: abc.'] }); + expect(auditRun(item, manifest([], { agentCommits: ['abc'] }), exact)).toEqual({ kind: 'violation', violations: ['The agent made its own commits: "abc".'] }); for (const field of ['agentCommits', 'linkTargetChanges', 'nestedGitlinkContent'] as const) expect(auditRun(item, manifest([file('src/retry.ts')], { [field]: undefined as unknown as string[] }), exact)).toEqual({ kind: 'violation', violations: [`The change report has no ${field} list.`] }); }); @@ -54,6 +55,20 @@ describe('post-run audit', () => { expect((outcome as { violations: string[] }).violations.join(' '), label).toMatch(reason); } }); + it('refuses a report that lists one path twice', () => { + expect(auditRun(item, manifest([file('src/retry.ts'), file('src/retry.ts', { kind: 'delete', newType: undefined })]), exact)) + .toEqual({ kind: 'violation', violations: ['The change report lists "src/retry.ts" more than once.'] }); + expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: 'docs/old.md' }), file('docs/old.md')]), exact)).toMatchObject({ kind: 'violation' }); + }); + it('refuses a declared link retargeted into .git in any case or at any depth', () => { + for (const target of ['.GIT/config', '.Git', 'vendor/.git/hooks', 'sub/.GiT']) + expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) + .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); + }); + it('quotes agent-controlled paths in findings and cuts long lists short', () => { + const outcome = auditRun(item, manifest([file('src/retry.ts')], { nestedGitlinkContent: ['a', 'b', 'c', 'd', 'e', 'f', 'g"; rm -rf /'] }), exact); + expect(outcome).toEqual({ kind: 'violation', violations: ['Content appeared under a gitlink: "a", "b", "c", "d", "e" and 2 more.'] }); + }); it('refuses a change path of . or .., any non-canonical spelling, and .git in any case at any depth', () => { for (const path of ['..', '.', 'a/../..', 'x/../.git/hooks/pre-commit', './.git/config', './a.ts', 'a//b', 'dir/', 'sub/.git/config', '.GIT/config', 'src/.Git']) expect(auditRun(item, manifest([file(path)]), exact), path).toMatchObject({ kind: 'violation' }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 0615e06a..e1f3845b 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { Store } from '../runner/store.ts'; import { RunnerCoordinator } from '../runner/coordinator.ts'; import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; -import { ShuttingDownError, type ShutdownCapability } from '../runner/lifecycle.ts'; +import { MAX_REASON, ShuttingDownError, type ShutdownCapability } from '../runner/lifecycle.ts'; import type { ChangeManifest, ManifestChange } from '../core/run-audit.ts'; import type { InvocationResult } from '../agents/contract.ts'; import type { Plan, PlanContext } from '../core/plan.ts'; @@ -67,7 +67,7 @@ function setup(options: { manifests?: Record { await runner.close(); store.close(); }); - return { store, path, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; + return { store, path, workspace, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; } describe('item execution', () => { @@ -163,7 +163,7 @@ describe('item execution', () => { it('sends the task to needs human when the change inspection refuses', async () => { const { store, executor, commits, log } = setup({ inspect: async () => { throw new Error('manifest digest mismatch'); } }); expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', - reason: `${SAFETY_VIOLATION} The change inspection refused: manifest digest mismatch` }); + reason: `${SAFETY_VIOLATION} The change inspection refused: "manifest digest mismatch"` }); expect(commits).toEqual([]); expect(store.getTask(identity).status).toBe('needs human'); expect(log).toContain('release P1 after failed'); @@ -254,7 +254,7 @@ describe('item execution', () => { } }); runner = h.runner; store = h.store; expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', - reason: `${SAFETY_VIOLATION} The change inspection refused: metadata digest changed` }); + reason: `${SAFETY_VIOLATION} The change inspection refused: "metadata digest changed"` }); expect(store.getTask(identity).status).toBe('needs human'); }); it('makes no commit when a stop lands during an inspection that ignores the abort', async () => { @@ -324,7 +324,7 @@ describe('item execution', () => { expect(runner.status(identity).unresolved).toBeNull(); expect(log).toContain('release P1 after failed'); }); - it('keeps a status someone set during release instead of pausing or escalating over it', async () => { + it('keeps a human gate set during release instead of pausing over it, but still escalates a safety violation', async () => { let store!: Store; const toApproval = async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); }; const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: toApproval }); @@ -333,13 +333,13 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('needs approval'); const unsafe = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: toApproval }); store = unsafe.store; - expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); - expect(store.getTask(identity).status).toBe('needs approval'); + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); }); it('treats an audit that throws as a safety violation', async () => { const { store, executor, commits } = setup({ pathKeyError: new Error('Non-ASCII case-insensitive paths require an adapter.') }); expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', - reason: `${SAFETY_VIOLATION} The change report could not be audited: Non-ASCII case-insensitive paths require an adapter.` }); + reason: `${SAFETY_VIOLATION} The change report could not be audited: "Non-ASCII case-insensitive paths require an adapter."` }); expect(store.getTask(identity).status).toBe('needs human'); expect(commits).toEqual([]); }); @@ -408,7 +408,7 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); expect(store.getTask(identity).status).toBe('running'); }); - it('keeps a queued status someone set during release: this run neither pauses nor escalates over it', async () => { + it('keeps a queued status set during release instead of pausing, but escalates a safety violation so the item is not re-run', async () => { let store!: Store; const toQueued = async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); }; const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: toQueued }); @@ -417,8 +417,10 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('queued'); const unsafe = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: toQueued }); store = unsafe.store; - expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/is queued, so it was not moved to needs human/) }); - expect(store.getTask(identity).status).toBe('queued'); + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', state: 'not started' }); + expect(store.getAttempts(identity)).toHaveLength(1); }); it('keeps task storage when a foreign result\'s terminal write fails', async () => { const { runner, executor, log } = setup({ settleError: true, exit: { P1: { attemptId: '00000000-0000-4000-8000-000000000000' } } }); @@ -426,4 +428,58 @@ describe('item execution', () => { expect(log.some(line => line.startsWith('release'))).toBe(false); expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); }); + it('leaves a safety violation\'s task alone when it was cancelled during release', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/is cancelled, so it was not moved/) }); + expect(store.getTask(identity).status).toBe('cancelled'); + }); + it('removes task storage after the terminal write when a stop lands during preparation', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup(); + runner = h.runner; store = h.store; + const snapshot = h.workspace.snapshotDeclaredLinks.bind(h.workspace); + h.workspace.snapshotDeclaredLinks = async (ws, paths, signal) => { runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); return snapshot(ws, paths, signal); }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(h.log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after cancelled']); + }); + it('removes task storage after the terminal write when the context goes stale before launch', async () => { + let store!: Store; + const h = setup(); + store = h.store; + const snapshot = h.workspace.snapshotDeclaredLinks.bind(h.workspace); + h.workspace.snapshotDeclaredLinks = async (ws, paths, signal) => { + store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', 'hash-2'); return snapshot(ws, paths, signal); + }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'stale' }); + expect(h.log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after stale']); + }); + it('refuses a scope pause whose executed prefix does not match the plan at the item\'s revision', () => { + const { store } = setup(); + const snapshotId = store.getSnapshot(identity).id; + expect(() => store.pauseForAmendment(identity, { revision: 1, snapshotId }, { item: 'P2', baseEntries: [], completedItems: ['P2'], outOfScopePaths: ['x'] })) + .toThrow(/executed plan prefix/); + expect(() => store.pauseForAmendment(identity, { revision: 1, snapshotId }, { item: 'P1', baseEntries: [], completedItems: ['P1', 'P2'], outOfScopePaths: ['x'] })) + .toThrow(/executed plan prefix/); + }); + it('bounds a finding whose text comes from the workspace', async () => { + const { executor } = setup({ inspect: async () => { throw new Error('x'.repeat(10_000)); } }); + const outcome = await executor.runTask(identity) as { kind: string; reason: string }; + expect(outcome.kind).toBe('needs human'); + expect(outcome.reason.length).toBeLessThanOrEqual(MAX_REASON); + }); + it('removes task storage after the terminal write when a cancel task lands on the row during preparation', async () => { + let store!: Store; + const h = setup(); + store = h.store; + const snapshot = h.workspace.snapshotDeclaredLinks.bind(h.workspace); + h.workspace.snapshotDeclaredLinks = async (ws, paths, signal) => { + store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); return snapshot(ws, paths, signal); + }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(h.log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after cancelled']); + expect(store.getTask(identity).status).toBe('cancelled'); + }); }); From 4af7574a2eb979e0bdb7f9fa9e49bf36558859fa Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:21:10 -0700 Subject: [PATCH 10/26] Fix round 7 of the F2b review: owed safety findings, human gates kept, undeclared link removals - A safety finding is settled only once acted on. If moving the task to needs human fails, or the task sits at a human gate, the finding stays owed and the task's next run escalates it before launching anything. - Escalation moves only a running or queued task: leaving a human-gated or review status needs its own user action (runner-lifecycle.md). Round 6 had escalated over any open status. - Between items, a status someone changed during release stops the run. - Removing a pre-existing symlink, or turning it into a file, at an undeclared path is a safety violation. - The path-size limit counts a rename's old path, which round 6 made a saved scope finding when undeclared. Duplicate paths are found under the plan's path identity. - A refused runner commit fails with a quoted message. - Tests for each, and for the owed-pause status rule and the prefix check against the item's own revision. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 20 +++++++----- runner/execution.ts | 60 +++++++++++++++++++++++++---------- test/run-audit.test.ts | 15 +++++++-- test/runner-execution.test.ts | 37 +++++++++++++++++++-- 4 files changed, 103 insertions(+), 29 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 386194ca..263acb04 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -52,7 +52,6 @@ function malformed(manifest: ChangeManifest): string | null { if (!Array.isArray(manifest[field]) || manifest[field].some(entry => typeof entry !== 'string')) return `The change report has no ${field} list.`; if (typeof manifest.metadataChanged !== 'boolean') return 'The change report does not say whether Git metadata changed.'; let bytes = 0; - const seen = new Set(); for (const change of manifest.changes) { if (!change || typeof change !== 'object' || typeof change.path !== 'string' || !change.path) return 'A change has no path.'; // Only a rename has an old path; on any other kind it would be staged as if it were part of the change. @@ -65,13 +64,8 @@ function malformed(manifest: ChangeManifest): string | null { if ((needsNew && !TYPES.has(change.newType as string)) || (!needsNew && change.newType !== undefined)) return `The change at ${q(change.path)} has an invalid new entry type.`; if (change.newType === 'symlink' && typeof change.newLinkTarget !== 'string') return `The link at ${q(change.path)} has no target.`; if (change.linkTargetTraversesLink !== undefined && typeof change.linkTargetTraversesLink !== 'boolean') return `The link at ${q(change.path)} has an invalid traversal flag.`; - // Each path appears once: two entries for one path (a modify and a delete, say) contradict each other. - for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) { - if (seen.has(path)) return `The change report lists ${q(path)} more than once.`; - seen.add(path); - } - // Only `path` is saved (in the result's scope lists and a checkpoint), so only it counts, as the JSON that stores it. - bytes += Buffer.byteLength(JSON.stringify(change.path)) + 1; + // Both paths can be saved (an undeclared rename source is a scope finding), measured as the JSON that stores them. + for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) bytes += Buffer.byteLength(JSON.stringify(path)) + 1; } if (bytes > MAX_PATH_BYTES) return 'The change report is too large to audit.'; return null; @@ -103,6 +97,13 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (manifest.linkTargetChanges.length) violations.push(`A declared symlink target changed: ${list(manifest.linkTargetChanges)}.`); if (manifest.nestedGitlinkContent.length) violations.push(`Content appeared under a gitlink: ${list(manifest.nestedGitlinkContent)}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); + // Each path appears once, under the trusted path identity: two entries for one path contradict each other. + const seen = new Set(); + for (const change of manifest.changes) for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) { + const key = pathKey(path); + if (seen.has(key)) return { kind: 'violation', violations: [`The change report lists ${q(path)} more than once.`] }; + seen.add(key); + } for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; // A path must be in canonical form: another spelling (./, a/../, //, a trailing /) could reach .git or hide a match. @@ -113,6 +114,9 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) { violations.push(`Invalid path in the change report: ${q(change.path)}.`); continue; } if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${q(change.path)}.`); continue; } + // Only a declared pre-existing link may change at all: deleting one, or turning it into a file, is a link change too. + if (change.oldType === 'symlink' && change.newType !== 'symlink' && !declared.has(pathKey(change.oldPath ?? change.path))) + { violations.push(`A pre-existing symlink was removed or replaced at an undeclared path: ${q(change.oldPath ?? change.path)}.`); continue; } if (change.newType === 'symlink') { if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${q(change.path)}.`); continue; } if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${q(change.path)}.`); continue; } diff --git a/runner/execution.ts b/runner/execution.ts index 85fbb465..1878a91a 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -47,7 +47,9 @@ export const SAFETY_VIOLATION = 'Safety violation:'; export class SafetyFindings { #found = new Map(); record(attemptId: string, reason: string): void { this.#found.set(attemptId, reason); } - take(attemptId: string): string | undefined { const reason = this.#found.get(attemptId); this.#found.delete(attemptId); return reason; } + /** A finding stays owed until its task has been moved to needs human (or closed); only then is it settled. */ + get(attemptId: string): string | undefined { return this.#found.get(attemptId); } + settle(attemptId: string): void { this.#found.delete(attemptId); } } export interface ExecutionResult { head: string; unchanged: boolean; inScope: string[]; outOfScope: string[] } interface Private { workspace: WorkspaceRef; prompt: string; baseHead: string; linkSnapshot: unknown } @@ -108,10 +110,16 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag if (!sameContext(attempt.context, store.currentContext(identity))) throw new FinishFailure('The plan, snapshot or assignment changed during the audit; nothing was committed.'); // Every change is in or out of scope here; a rename stages both its old and its new path. const paths = [...new Set(manifest.changes.flatMap(entry => [entry.path, ...(entry.oldPath ? [entry.oldPath] : [])]))]; - const head = await workspace.commit(data.workspace, { + let head: string; + try { head = await workspace.commit(data.workspace, { baseHead: data.baseHead, paths, digest: manifest.digest, message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, - }, signal); + }, signal); } + catch (error) { + if (signal.aborted && (error === signal.reason || (error instanceof Error && error.name === 'AbortError'))) throw error; + // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). + throw new FinishFailure(`The runner commit was refused: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); + } // The ID goes into the ledger inside the terminal write; a malformed one must fail the attempt, not that write. if (typeof head !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(head)) throw new FinishFailure('The workspace returned an invalid commit ID; nothing was published.'); if (head === data.baseHead) throw new FinishFailure('The workspace made no new commit for a changed item; nothing was published.'); @@ -164,6 +172,11 @@ export class ItemExecutor { if (start < 0) throw new Error('Unknown plan item.'); const done: string[] = [], unchanged: string[] = []; const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); + // A safety finding not yet acted on (a failed write, a human gate at the time) goes to needs human first. + for (const earlier of this.#store.getAttempts(identity)) { + const finding = this.#findings.get(earlier.id); + if (finding) return this.#escalate(identity, earlier, finding, stopped, []); + } // A scope finding whose pause was never recorded (a failed write, the write gate, a crash) pauses now, before any item. const owed = this.#unpausedScopeFinding(identity); if (owed) return this.#pause(identity, owed.row, owed.result, stopped, [], true); @@ -178,6 +191,9 @@ export class ItemExecutor { for (const item of plan.items.slice(start)) { // Admission reads the context in this same turn, so it cannot notice a change saved during an earlier item. const current = this.#store.currentContext(identity); + // After an item, the task is still running unless someone changed its status meanwhile: then the run stops. + if (expected && this.#store.getTask(identity).status !== 'running') + return stopped(item.id, 'not started', `The task's status changed to ${this.#store.getTask(identity).status} during the run; ${item.id} was not started.`); if (this.#store.getPlan(identity).revision !== plan.revision) return stopped(item.id, 'not started', `The plan changed to a new revision during the run; review it before running ${item.id}.`); if (expected && (current.snapshotId !== expected.snapshotId || current.assignmentId !== expected.assignmentId || current.referencedCodeHash !== expected.referencedCodeHash)) @@ -195,19 +211,8 @@ export class ItemExecutor { await this.#runner.settled(identity); const row = this.#store.getAttempt(identity, attempt.id); // Only the runner's own audit records a finding; it wins over any later stale or stop outcome. - const violation = this.#findings.take(attempt.id); - if (violation) { - const task = this.#store.getTask(identity); - // A safety violation goes to needs human over any status someone set since (queued, a human gate), because a - // run from that status would launch the item again; only a closed task is left as it is (no await since this read). - if (CLOSED_STATUSES.includes(task.status)) return stopped(item.id, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); - try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } - catch (error) { - if (!(error instanceof GuardRefusal)) throw error; - return stopped(item.id, row.state, `${violation} The task could not be moved to needs human: ${error.message}`); - } - return { kind: 'needs human', item: item.id, reason: violation, completed: [...done] }; - } + const violation = this.#findings.get(attempt.id); + if (violation) return this.#escalate(identity, row, violation, stopped, done); if (row.state !== 'completed') { // Still pending or running: the terminal write failed and the slot is held until restart. const unresolved = this.#runner.status(identity).unresolved; @@ -224,6 +229,29 @@ export class ItemExecutor { } return { kind: 'executed', items: done, unchanged }; } + /** + * A safety finding sends the task to needs human (plan-format.md, "After each run"). From running or queued it moves + * now. A human-gated or review status is kept, because leaving it needs its own user action (runner-lifecycle.md), + * and the finding stays owed: the task's next run escalates it before anything else. A closed task needs nothing. + * The finding is settled only once acted on, so a failed write leaves it owed too. + */ + #escalate(identity: PlanIdentity, row: AttemptRecord, violation: string, + stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[]): ExecutionOutcome { + const item = row.item!, task = this.#store.getTask(identity); + if (CLOSED_STATUSES.includes(task.status)) { + this.#findings.settle(row.id); + return stopped(item, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); + } + if (task.status !== 'running' && task.status !== 'queued') + return stopped(item, row.state, `${violation} The task is ${task.status}; it moves to needs human when it next runs.`); + try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } + catch (error) { + if (!(error instanceof GuardRefusal)) throw error; + return stopped(item, row.state, `${violation} The task could not be moved to needs human yet: ${error.message}`); + } + this.#findings.settle(row.id); + return { kind: 'needs human', item, reason: violation, completed: [...done] }; + } /** The latest completed execute attempt, if its out-of-scope files have no checkpoint yet (its pause was lost). */ #unpausedScopeFinding(identity: PlanIdentity): { row: AttemptRecord; result: ExecutionResult } | null { const row = this.#store.getAttempts(identity).filter(entry => entry.kind === 'execute' && entry.state === 'completed').at(-1); diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index d331875b..3d408537 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -65,6 +65,15 @@ describe('post-run audit', () => { expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); }); + it('refuses removing or replacing a pre-existing symlink at an undeclared path, and allows it at a declared one', () => { + for (const change of [file('lnk', { kind: 'delete', oldType: 'symlink', newType: undefined }), file('lnk', { oldType: 'symlink', newType: 'file' })]) + expect(auditRun(item, manifest([change]), exact)).toEqual({ kind: 'violation', violations: ['A pre-existing symlink was removed or replaced at an undeclared path: "lnk".'] }); + expect(auditRun(item, manifest([file('link', { kind: 'delete', oldType: 'symlink', newType: undefined })]), exact)).toMatchObject({ kind: 'commit', inScope: ['link'] }); + }); + it('counts two spellings of one path under a case-folding identity as a duplicate', () => { + expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), folded)).toMatchObject({ kind: 'violation' }); + expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), exact)).toMatchObject({ kind: 'commit' }); + }); it('quotes agent-controlled paths in findings and cuts long lists short', () => { const outcome = auditRun(item, manifest([file('src/retry.ts')], { nestedGitlinkContent: ['a', 'b', 'c', 'd', 'e', 'f', 'g"; rm -rf /'] }), exact); expect(outcome).toEqual({ kind: 'violation', violations: ['Content appeared under a gitlink: "a", "b", "c", "d", "e" and 2 more.'] }); @@ -74,12 +83,12 @@ describe('post-run audit', () => { expect(auditRun(item, manifest([file(path)]), exact), path).toMatchObject({ kind: 'violation' }); expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: 'x/../.git/config' })]), exact)).toMatchObject({ kind: 'violation' }); }); - it('limits the saved paths as JSON, and does not count a rename\'s old path', () => { + it('limits the saved paths as JSON, a rename\'s old path included (an undeclared one is saved as the finding)', () => { // 100 paths of 4,000 control characters are under the raw byte cap but about 2.4 MB as JSON. expect(auditRun(item, manifest(Array.from({ length: 100 }, (_, i) => file(`${'\u0001'.repeat(4000)}${i}`, { kind: 'add', oldType: undefined }))), exact)) .toEqual({ kind: 'violation', violations: ['The change report is too large to audit.'] }); - // A long old path is not saved, so it does not count. - expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: `docs/${'o'.repeat(600_000)}.md` })]), exact)).toMatchObject({ kind: 'commit' }); + expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: `docs/${'o'.repeat(600_000)}.md` })]), exact)) + .toEqual({ kind: 'violation', violations: ['The change report is too large to audit.'] }); }); it('stops on every safety violation before any scope decision', () => { const cases: [string, ChangeManifest][] = [ diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index e1f3845b..da71999c 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -120,7 +120,7 @@ describe('item execution', () => { }); it('records no ledger entry when the commit is refused', async () => { const { store, executor } = setup({ commit: async () => { throw new Error('work tree changed after the audit'); } }); - expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Invalid output: work tree changed after the audit' }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'The runner commit was refused: "work tree changed after the audit"' }); expect(store.getLedger(identity)).toEqual([]); }); it('discards the commit when a stop lands while the commit runs', async () => { @@ -324,7 +324,7 @@ describe('item execution', () => { expect(runner.status(identity).unresolved).toBeNull(); expect(log).toContain('release P1 after failed'); }); - it('keeps a human gate set during release instead of pausing over it, but still escalates a safety violation', async () => { + it('keeps a human gate set during release, and escalates a safety violation owed from it when the task next runs', async () => { let store!: Store; const toApproval = async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); }; const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: toApproval }); @@ -333,8 +333,13 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('needs approval'); const unsafe = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: toApproval }); store = unsafe.store; + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/needs approval; it moves to needs human when it next runs/) }); + expect(store.getTask(identity).status).toBe('needs approval'); + // A person releases the gate; the owed finding goes to needs human before any item runs again. + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); expect(store.getTask(identity).status).toBe('needs human'); + expect(store.getAttempts(identity)).toHaveLength(1); }); it('treats an audit that throws as a safety violation', async () => { const { store, executor, commits } = setup({ pathKeyError: new Error('Non-ASCII case-insensitive paths require an adapter.') }); @@ -482,4 +487,32 @@ describe('item execution', () => { expect(h.log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after cancelled']); expect(store.getTask(identity).status).toBe('cancelled'); }); + it('keeps a safety finding owed when moving to needs human fails, and escalates it before the next run launches anything', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) } }); + const transition = h.store.transitionTask.bind(h.store); + let fail = true; + h.store.transitionTask = (...args) => { if (fail && args[2] === 'needs human') { fail = false; throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); } return transition(...args); }; + await expect(h.executor.runTask(identity)).rejects.toThrow(/disk full/); + expect(h.store.getTask(identity).status).toBe('running'); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(h.store.getAttempts(identity)).toHaveLength(1); + }); + it('stops before the next item when someone changes the status during release', async () => { + let store!: Store; + const h = setup({ release: async () => { if (store.getAttempts(identity).length === 1) store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', reason: expect.stringMatching(/changed to queued/) }); + expect(h.commits.map(c => c.item)).toEqual(['P1']); + }); + it('refuses an owed pause from a human gate, and validates the prefix against the item\'s own revision', () => { + const { store } = setup(); + const snapshotId = store.getSnapshot(identity).id; + // Revision 2 drops P2; the prefix [P1, P2] is still right for revision 1, where the item ran. + store.importRevision(JSON.stringify({ ...plan, revision: 2, items: [plan.items[0]!] }), 'json', context, 1); + const evidence = { item: 'P2', baseEntries: [], completedItems: ['P1', 'P2'], outOfScopePaths: ['x'] }; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); + expect(() => store.pauseForAmendment(identity, { revision: 1, snapshotId }, evidence, { owed: true })).toThrow(/is needs approval/); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + expect(store.pauseForAmendment(identity, { revision: 1, snapshotId }, evidence, { owed: true })).toMatchObject({ revision: 1, completedItems: ['P1', 'P2'] }); + }); }); From b3196c79dd84e634840ba9e4c90bbdacd10a95b1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:34:46 -0700 Subject: [PATCH 11/26] Fix round 8 of the F2b review: review statuses escalate, Git's .git spellings, undeclared link renames - A safety finding moves a review status (in review, approved but merge blocked) to needs human, so the task cannot be merged past it; only the human gates keep the finding owed. A task already in needs human settles the finding, so it is not escalated a second time. - Renaming a pre-existing symlink from an undeclared path is a violation, like removing or replacing it. - isDotGit refuses every spelling Git treats as .git (any case; NTFS trailing dots or spaces and git~1; HFS ignorable code points), in paths and link targets. - A case-only rename counts once under a case-folding identity. - runner-lifecycle.md lists every unresolved marker reason. - Tests for each, and for escalation refused by a merge in progress (the finding stays owed), escalation through the capability after the gate, an owed finding settled on a closed task, and completed plus the ledger entry being one transaction. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 32 ++++++++----- docs/implementation/runner-lifecycle.md | 2 +- runner/execution.ts | 18 ++++++-- test/run-audit.test.ts | 18 +++++++- test/runner-execution.test.ts | 60 ++++++++++++++++++++++++- 5 files changed, 112 insertions(+), 18 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 263acb04..390ca8f3 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -36,6 +36,14 @@ const MAX_CHANGES = 10_000; /** Every path in the report together; keeps the saved result (scope lists included) far below its 1 MiB limit. */ const MAX_PATH_BYTES = 480 * 1024; const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); +/** + * Whether a path part names Git's metadata directory in any spelling Git itself refuses (read-cache.c, verify_path): + * any case; on NTFS with trailing dots or spaces and as the 8.3 short name `git~1`; on HFS with ignorable code points. + */ +export function isDotGit(part: string): boolean { + const plain = part.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, '').toLowerCase().replace(/[. ]+$/, ''); + return plain === '.git' || plain === 'git~1'; +} /** Agent-controlled text in a finding is quoted (AGENTS.md), and each list is cut short, so a reason stays readable. */ const q = (text: string) => JSON.stringify(text.length > 300 ? `${text.slice(0, 300)}…` : text); const list = (items: readonly string[]) => items.slice(0, 5).map(q).join(', ') + (items.length > 5 ? ` and ${items.length - 5} more` : ''); @@ -78,7 +86,7 @@ function unsafeLinkTarget(linkPath: string, target: string): string | null { const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)); if (resolved === '..' || resolved.startsWith('../')) return 'target leaves the repository'; // As for paths: Git's metadata is `.git` in any case, at any depth. - if (resolved.split('/').some((part: string) => part.toLowerCase() === '.git')) return 'target enters .git'; + if (resolved.split('/').some(isDotGit)) return 'target enters .git'; return null; } @@ -99,24 +107,28 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); // Each path appears once, under the trusted path identity: two entries for one path contradict each other. const seen = new Set(); - for (const change of manifest.changes) for (const path of [change.path, ...(change.oldPath ? [change.oldPath] : [])]) { - const key = pathKey(path); - if (seen.has(key)) return { kind: 'violation', violations: [`The change report lists ${q(path)} more than once.`] }; - seen.add(key); + for (const change of manifest.changes) { + // A case-only rename's two sides are one path under a folding identity; count it once for this change. + const keys = new Set([change.path, ...(change.oldPath ? [change.oldPath] : [])].map(pathKey)); + for (const key of keys) { + if (seen.has(key)) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] }; + seen.add(key); + } } for (const change of manifest.changes) { const paths = [change.path, ...(change.oldPath ? [change.oldPath] : [])]; // A path must be in canonical form: another spelling (./, a/../, //, a trailing /) could reach .git or hide a match. if (paths.some(path => path !== posix.normalize(path) || path.endsWith('/') || path.startsWith('./'))) { violations.push(`Path not in canonical form in the change report: ${q(change.path)}.`); continue; } - // Git refuses a .git part of any case at any depth, so the audit does too. - if (change.underGit || paths.some(path => path.split('/').some((part: string) => part.toLowerCase() === '.git'))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; } + // Git refuses a .git part in any spelling it treats as .git, at any depth, so the audit does too. + if (change.underGit || paths.some(path => path.split('/').some(isDotGit))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; } if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) { violations.push(`Invalid path in the change report: ${q(change.path)}.`); continue; } if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${q(change.path)}.`); continue; } - // Only a declared pre-existing link may change at all: deleting one, or turning it into a file, is a link change too. - if (change.oldType === 'symlink' && change.newType !== 'symlink' && !declared.has(pathKey(change.oldPath ?? change.path))) - { violations.push(`A pre-existing symlink was removed or replaced at an undeclared path: ${q(change.oldPath ?? change.path)}.`); continue; } + // Only a declared pre-existing link may change at all: deleting it, turning it into a file, or renaming it from an + // undeclared path is a link change too. + if (change.oldType === 'symlink' && !declared.has(pathKey(change.oldPath ?? change.path))) + { violations.push(`A pre-existing symlink was changed at an undeclared path: ${q(change.oldPath ?? change.path)}.`); continue; } if (change.newType === 'symlink') { if (change.oldType !== 'symlink') { violations.push(`New symlink or file-to-symlink conversion: ${q(change.path)}.`); continue; } if (!declared.has(pathKey(change.path))) { violations.push(`A pre-existing symlink changed at an undeclared path: ${q(change.path)}.`); continue; } diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 1cc62884..1e5b4cca 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -292,7 +292,7 @@ This runs before the coordinator opens. ## HTTP and UI contract -**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" }`. The UI shows "Needs restart: result could not be saved" only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review. +**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved, or the host-side preparation files or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review. **User actions.** Cancel, retry and "run again" requests send `attemptId`, `expectedStateVersion` and an `actionId` idempotency key (see "Feedback-event contract"). A replayed `actionId` returns the saved outcome. The server takes the plan identity from its trusted configuration, never from the request, and every `Store` call is scoped by that identity. A mismatch returns HTTP 409 with the current state. The UI then shows that state and keeps any draft. diff --git a/runner/execution.ts b/runner/execution.ts index 1878a91a..8a6fc460 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -150,6 +150,11 @@ export type ExecutionOutcome = | { kind: 'needs human'; item: string; reason: string; completed: string[] } | { kind: 'stopped'; item: string; state: string; reason: string | null; completed: string[] }; const refusal = (error: unknown) => error instanceof GuardRefusal || error instanceof ShuttingDownError; +/** + * Statuses that wait for a person; leaving one needs its own user action (runner-lifecycle.md), so a safety finding is + * owed there instead. Review statuses are not gates: a finding moves them to needs human, so the task cannot be merged. + */ +const HUMAN_GATES: readonly string[] = ['needs amendment', 'needs approval', 'possibly already fixed']; /** * Runs a task's plan items in order, one execute attempt each. Stops at the first item that does not complete cleanly: @@ -230,9 +235,9 @@ export class ItemExecutor { return { kind: 'executed', items: done, unchanged }; } /** - * A safety finding sends the task to needs human (plan-format.md, "After each run"). From running or queued it moves - * now. A human-gated or review status is kept, because leaving it needs its own user action (runner-lifecycle.md), - * and the finding stays owed: the task's next run escalates it before anything else. A closed task needs nothing. + * A safety finding sends the task to needs human (plan-format.md, "After each run"). From running, queued or a review + * status it moves now. A human gate is kept, because leaving it needs its own user action (runner-lifecycle.md), and + * the finding stays owed: the task's next run escalates it before anything else. A closed task needs nothing. * The finding is settled only once acted on, so a failed write leaves it owed too. */ #escalate(identity: PlanIdentity, row: AttemptRecord, violation: string, @@ -242,7 +247,12 @@ export class ItemExecutor { this.#findings.settle(row.id); return stopped(item, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); } - if (task.status !== 'running' && task.status !== 'queued') + // Already where the finding sends it: nothing is owed. + if (task.status === 'needs human') { + this.#findings.settle(row.id); + return { kind: 'needs human', item, reason: violation, completed: [...done] }; + } + if (HUMAN_GATES.includes(task.status)) return stopped(item, row.state, `${violation} The task is ${task.status}; it moves to needs human when it next runs.`); try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } catch (error) { diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 3d408537..9de559ed 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -65,11 +65,25 @@ describe('post-run audit', () => { expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); }); - it('refuses removing or replacing a pre-existing symlink at an undeclared path, and allows it at a declared one', () => { + it('refuses removing, replacing or renaming a pre-existing symlink from an undeclared path, and allows it at a declared one', () => { for (const change of [file('lnk', { kind: 'delete', oldType: 'symlink', newType: undefined }), file('lnk', { oldType: 'symlink', newType: 'file' })]) - expect(auditRun(item, manifest([change]), exact)).toEqual({ kind: 'violation', violations: ['A pre-existing symlink was removed or replaced at an undeclared path: "lnk".'] }); + expect(auditRun(item, manifest([change]), exact)).toEqual({ kind: 'violation', violations: ['A pre-existing symlink was changed at an undeclared path: "lnk".'] }); + expect(auditRun(item, manifest([file('link', { kind: 'rename', oldPath: 'lnk', oldType: 'symlink', newType: 'symlink', newLinkTarget: 'src/retry.ts', linkTargetTraversesLink: false })]), exact)) + .toEqual({ kind: 'violation', violations: ['A pre-existing symlink was changed at an undeclared path: "lnk".'] }); expect(auditRun(item, manifest([file('link', { kind: 'delete', oldType: 'symlink', newType: undefined })]), exact)).toMatchObject({ kind: 'commit', inScope: ['link'] }); }); + it('refuses every spelling Git treats as .git, in paths and link targets', () => { + for (const path of ['.git /config', '.git./hooks', 'GIT~1/config', 'sub/.g\u200cit/hooks/post-checkout', '.GIT\ufeff']) + expect(auditRun(item, manifest([file(path)]), exact), path).toMatchObject({ kind: 'violation' }); + for (const target of ['.git.', 'GIT~1', '.g\u200dit/config']) + expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) + .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); + expect(auditRun(item, manifest([file('src/.github-notes.md', { kind: 'add', oldType: undefined })]), exact)).toMatchObject({ kind: 'commit' }); + }); + it('counts a case-only rename once under a case-folding identity', () => { + const renamed: PlanItem = { ...item, files: [{ path: 'README.md', kind: 'rename', renamed_from: 'Readme.md', change: 'x' }] }; + expect(auditRun(renamed, manifest([file('README.md', { kind: 'rename', oldPath: 'Readme.md' })]), folded)).toMatchObject({ kind: 'commit', inScope: ['README.md'] }); + }); it('counts two spellings of one path under a case-folding identity as a duplicate', () => { expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), folded)).toMatchObject({ kind: 'violation' }); expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), exact)).toMatchObject({ kind: 'commit' }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index da71999c..e3b1d0f8 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -67,7 +67,7 @@ function setup(options: { manifests?: Record { await runner.close(); store.close(); }); - return { store, path, workspace, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; + return { store, path, workspace, findings, runner, executor: new ItemExecutor(store, runner, sources, findings, { capability }), log, commits, prompts, argv, owners }; } describe('item execution', () => { @@ -515,4 +515,62 @@ describe('item execution', () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); expect(store.pauseForAmendment(identity, { revision: 1, snapshotId }, evidence, { owed: true })).toMatchObject({ revision: 1, completedItems: ['P1', 'P2'] }); }); + it('escalates a safety violation over a review status, so the task cannot be merged past it', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('settles a finding whose task is already needs human, so it is not escalated again later', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { if (store.getAttempts(identity).length === 1) store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(h.findings.get(store.getAttempts(identity)[0]!.id)).toBeUndefined(); + }); + it('keeps a finding owed when a merge in progress refuses the escalation, and escalates it on the next run', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: async () => { + store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); + const snapshot = store.getSnapshot(identity); + store.beginMergeAttempt(identity, { revision: 1, snapshotId: snapshot.id, reviewVersion: store.reviewVersion(identity) }, snapshot.head, null, 'direct'); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/could not be moved to needs human yet: A merge is in progress/) }); + const attemptId = store.getAttempts(identity)[0]!.id; + expect(h.findings.get(attemptId)).toBeDefined(); + store.finishMergeAttempt(identity, store.getMergeAttempt(identity)!.id, { state: 'failed', reason: 'GitHub refused.' }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(h.findings.get(attemptId)).toBeUndefined(); + }); + it('escalates through the capability after the shutdown write gate closed', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, capability: s => s.shutdownCapability(), + release: async () => { store.closeWrites(); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('settles an owed finding when the task was closed before its next run', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + await h.executor.runTask(identity); + const attemptId = store.getAttempts(identity)[0]!.id; + expect(h.findings.get(attemptId)).toBeDefined(); + store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/is cancelled/) }); + expect(h.findings.get(attemptId)).toBeUndefined(); + }); + it('records completed and the ledger entry in one transaction: a failed history write leaves neither', async () => { + const h = setup(); + h.store.recordHistory = () => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'running' }); + expect(h.store.getLedger(identity)).toEqual([]); + expect(h.runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); + }); }); From c91dfa7c60121a91e11d2935801e3b9d1b27de40 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:45:34 -0700 Subject: [PATCH 12/26] Fix round 9 of the F2b review: scope pauses over review statuses, NTFS .git names, links to the root - A scope pause is recorded over a review status someone set during release (in review, approved but merge blocked), in the run that found it and when owed, so a merge cannot go past the finding; round 8 did this only for safety findings. A queued status set during release is still kept, and the next run pays the pause. - isDotGit ends a name where NTFS does (a stream separator or a backslash) before comparing it with .git. - A declared link retargeted to the repository root (., ./, a/..) is refused: the root contains .git. - A change report without a digest fails closed. - Tests for each, and for a declared link renamed to an undeclared path, a directory entry, an absolute path, every ignorable code point range, and escalation over approved but merge blocked. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 9 +++++++-- runner/execution.ts | 2 ++ runner/store.ts | 8 +++++--- test/run-audit.test.ts | 17 +++++++++++++++++ test/runner-execution.test.ts | 23 +++++++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 390ca8f3..e02bf9d2 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -41,7 +41,9 @@ const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); * any case; on NTFS with trailing dots or spaces and as the 8.3 short name `git~1`; on HFS with ignorable code points. */ export function isDotGit(part: string): boolean { - const plain = part.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, '').toLowerCase().replace(/[. ]+$/, ''); + // NTFS ends a name at a stream separator (`:`) or a backslash, then drops trailing dots and spaces. + const name = part.split(/[:\\]/)[0]!; + const plain = name.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, '').toLowerCase().replace(/[. ]+$/, ''); return plain === '.git' || plain === 'git~1'; } /** Agent-controlled text in a finding is quoted (AGENTS.md), and each list is cut short, so a reason stays readable. */ @@ -83,8 +85,11 @@ function malformed(manifest: ChangeManifest): string | null { function unsafeLinkTarget(linkPath: string, target: string): string | null { if (!target || target.includes('\0')) return 'empty or invalid target'; if (target.startsWith('/')) return 'absolute target'; - const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)); + // A trailing slash names the same directory: `./` and `a/../` are the root, like `.`. + const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)).replace(/\/+$/, '') || '.'; if (resolved === '..' || resolved.startsWith('../')) return 'target leaves the repository'; + // The repository root contains .git: a link to it reaches the metadata through one more path part. + if (resolved === '.') return 'target is the repository root'; // As for paths: Git's metadata is `.git` in any case, at any depth. if (resolved.split('/').some(isDotGit)) return 'target enters .git'; return null; diff --git a/runner/execution.ts b/runner/execution.ts index 8a6fc460..6119ed99 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -100,6 +100,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag // Contract (Publishing step 2): an inspection that refuses sends the task to needs human. return violation(`The change inspection refused: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } + // The commit step refuses a tree that no longer matches this digest; without one that guard has nothing to check. + if (typeof manifest?.digest !== 'string' || !manifest.digest) return violation('The change report has no digest.'); let outcome: ReturnType; try { outcome = auditRun(item, manifest, sources.planContext(identity).pathKey); } catch (error) { return violation(`The change report could not be audited: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } diff --git a/runner/store.ts b/runner/store.ts index b9f099d5..e9594f4a 100644 --- a/runner/store.ts +++ b/runner/store.ts @@ -516,10 +516,12 @@ export class Store { const ids = this.getPlan(identity, ranAt.revision).items.map(item => item.id); if (!ids.includes(evidence.item) || evidence.completedItems.at(-1) !== evidence.item || new Set(evidence.completedItems).size !== evidence.completedItems.length || evidence.completedItems.some((item, i) => item !== ids[i])) throw new Error('Checkpoint must describe the executed plan prefix.'); - // Only the executor's own task pauses. In the run that found it, that is a running task: any status someone set - // since (queued included) is kept. A pause owed from an earlier run is also paid from queued, the next run's start. + // Only the executor's own task pauses. In the run that found it, that is a running task, or a review status someone + // set since (a merge must not go past the finding); a queued status set since is kept, and the next run pays the + // pause from there. A human gate is kept too. A pause owed from an earlier run is paid from queued as well. const status = this.#task(key).status; - if (status !== 'running' && !(options.owed && status === 'queued')) throw new GuardRefusal(`The task is ${status}, so it was not paused for amendment.`); + const pausable = status === 'running' || status === 'in review' || status === 'approved but merge blocked' || (options.owed === true && status === 'queued'); + if (!pausable) throw new GuardRefusal(`The task is ${status}, so it was not paused for amendment.`); this.transitionTask(identity, this.#task(key).state_version as number, 'needs amendment'); const checkpoint = { ...evidence, revision: ranAt.revision, snapshotId: ranAt.snapshotId, id: randomUUID() }; this.#run('INSERT INTO checkpoints VALUES (?,?,?)', key, checkpoint.id, encode(checkpoint)); diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 9de559ed..d7bb62d0 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -80,6 +80,23 @@ describe('post-run audit', () => { .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); expect(auditRun(item, manifest([file('src/.github-notes.md', { kind: 'add', oldType: undefined })]), exact)).toMatchObject({ kind: 'commit' }); }); + it('ends a name where NTFS does (a stream separator or a backslash) before comparing it with .git', () => { + for (const path of ['.git:x', '.git::$INDEX_ALLOCATION/config', 'git~1:s', '.git\\config', 'GIT~1\\hooks', 'a/.g\u200eit', 'a/.g\u202ait', 'a/.g\u206bit', 'a/.gi\u200ft']) + expect(auditRun(item, manifest([file(path, { kind: 'add', oldType: undefined })]), exact), path).toMatchObject({ kind: 'violation' }); + }); + it('refuses a declared link retargeted to the repository root', () => { + for (const target of ['.', './', 'a/..']) + expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) + .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target is the repository root.'] }); + }); + it('refuses a declared link renamed to an undeclared path, a directory entry, and an absolute path', () => { + expect(auditRun(item, manifest([file('lnk2', { kind: 'rename', oldPath: 'link', oldType: 'symlink', newType: 'symlink', newLinkTarget: 'src/retry.ts', linkTargetTraversesLink: false })]), exact)) + .toEqual({ kind: 'violation', violations: ['A pre-existing symlink changed at an undeclared path: "lnk2".'] }); + expect(auditRun(item, manifest([file('build', { kind: 'add', oldType: undefined, newType: 'directory' })]), exact)) + .toEqual({ kind: 'violation', violations: ['Unexpected directory entry: "build".'] }); + expect(auditRun(item, manifest([file('/etc/passwd', { kind: 'add', oldType: undefined })]), exact)) + .toEqual({ kind: 'violation', violations: ['Invalid path in the change report: "/etc/passwd".'] }); + }); it('counts a case-only rename once under a case-folding identity', () => { const renamed: PlanItem = { ...item, files: [{ path: 'README.md', kind: 'rename', renamed_from: 'Readme.md', change: 'x' }] }; expect(auditRun(renamed, manifest([file('README.md', { kind: 'rename', oldPath: 'Readme.md' })]), folded)).toMatchObject({ kind: 'commit', inScope: ['README.md'] }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index e3b1d0f8..b65c9698 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -573,4 +573,27 @@ describe('item execution', () => { expect(h.store.getLedger(identity)).toEqual([]); expect(h.runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); }); + it('pauses for amendment over a review status set during release, so a merge cannot go past the finding', async () => { + for (const status of ['in review', 'approved but merge blocked'] as const) { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, status); } }); + store = h.store; + expect(await h.executor.runTask(identity), status).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + expect(store.getTask(identity).status, status).toBe('needs amendment'); + } + }); + it('escalates a safety violation over approved but merge blocked too', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'approved but merge blocked'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + }); + it('sends a change report without a digest to needs human', async () => { + const { store, executor, commits } = setup({ manifests: { P1: { ...manifest([change('a.ts')]), digest: undefined as unknown as string } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', reason: `${SAFETY_VIOLATION} The change report has no digest.` }); + expect(store.getTask(identity).status).toBe('needs human'); + expect(commits).toEqual([]); + }); }); From 1f1da604caaf91c1f6dbdd91ec2d9cc5e2bfaa44 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 10:57:18 -0700 Subject: [PATCH 13/26] Fix round 10 of the F2b review: a split case-only rename is one path - A case-only rename that Git reports as a delete and an add (the file also changed a lot) counts as one path under a case-folding identity, instead of a duplicate that sent the task to needs human. Undeclared, the same pair is a scope finding; two adds of one folded path are still refused. - The runner commit's error handling drops a redundant abort special case: a stop records its first reason before it aborts, so the outcome is that stop. - Tests for an owed pause paid from a review status, a pause never overwriting needs human, possibly already fixed as a gate, task storage kept before launch when the terminal write fails, checkpointAtHead, and the unknown-snapshot check. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 10 ++++++--- runner/execution.ts | 4 ++-- test/run-audit.test.ts | 7 +++++- test/runner-execution.test.ts | 42 +++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index e02bf9d2..08aabcb4 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -111,13 +111,17 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (manifest.nestedGitlinkContent.length) violations.push(`Content appeared under a gitlink: ${list(manifest.nestedGitlinkContent)}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); // Each path appears once, under the trusted path identity: two entries for one path contradict each other. - const seen = new Set(); + const seen = new Map(); for (const change of manifest.changes) { // A case-only rename's two sides are one path under a folding identity; count it once for this change. const keys = new Set([change.path, ...(change.oldPath ? [change.oldPath] : [])].map(pathKey)); for (const key of keys) { - if (seen.has(key)) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] }; - seen.add(key); + const earlier = seen.get(key); + // A case-only rename that Git reports as a delete and an add (the file also changed a lot) is one path too. + const splitRename = earlier && !earlier.oldPath && !change.oldPath && earlier.path !== change.path + && new Set([earlier.kind, change.kind]).size === 2 && [earlier.kind, change.kind].every(kind => kind === 'add' || kind === 'delete'); + if (earlier && !splitRename) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] }; + seen.set(key, change); } } for (const change of manifest.changes) { diff --git a/runner/execution.ts b/runner/execution.ts index 6119ed99..94a26034 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -118,8 +118,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); } catch (error) { - if (signal.aborted && (error === signal.reason || (error instanceof Error && error.name === 'AbortError'))) throw error; - // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). + // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). A stop records its first reason before it + // aborts, so a stopped commit still ends as that stop, whatever this text says. throw new FinishFailure(`The runner commit was refused: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`); } // The ID goes into the ledger inside the terminal write; a malformed one must fail the attempt, not that write. diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index d7bb62d0..4227f7c0 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -97,9 +97,14 @@ describe('post-run audit', () => { expect(auditRun(item, manifest([file('/etc/passwd', { kind: 'add', oldType: undefined })]), exact)) .toEqual({ kind: 'violation', violations: ['Invalid path in the change report: "/etc/passwd".'] }); }); - it('counts a case-only rename once under a case-folding identity', () => { + it('counts a case-only rename once under a case-folding identity, whether reported as a rename or as a delete and an add', () => { const renamed: PlanItem = { ...item, files: [{ path: 'README.md', kind: 'rename', renamed_from: 'Readme.md', change: 'x' }] }; expect(auditRun(renamed, manifest([file('README.md', { kind: 'rename', oldPath: 'Readme.md' })]), folded)).toMatchObject({ kind: 'commit', inScope: ['README.md'] }); + const split = manifest([file('Readme.md', { kind: 'delete', newType: undefined }), file('README.md', { kind: 'add', oldType: undefined })]); + expect(auditRun(renamed, split, folded)).toMatchObject({ kind: 'commit', inScope: ['Readme.md', 'README.md'], outOfScope: [] }); + // Undeclared, the same pair is a scope finding, not a safety violation; two adds of one folded path are still refused. + expect(auditRun(item, manifest([file('notes', { kind: 'delete', newType: undefined }), file('NOTES', { kind: 'add', oldType: undefined })]), folded)).toMatchObject({ kind: 'commit', outOfScope: ['notes', 'NOTES'] }); + expect(auditRun(item, manifest([file('notes', { kind: 'add', oldType: undefined }), file('NOTES', { kind: 'add', oldType: undefined })]), folded)).toMatchObject({ kind: 'violation' }); }); it('counts two spellings of one path under a case-folding identity as a duplicate', () => { expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), folded)).toMatchObject({ kind: 'violation' }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index b65c9698..5818e090 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -596,4 +596,46 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('needs human'); expect(commits).toEqual([]); }); + it('pays an owed scope pause from a review status too', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs amendment'); + }); + it('never turns needs human into needs amendment with a scope pause', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + expect(store.getTask(identity).status).toBe('needs human'); + }); + it('keeps possibly already fixed as a human gate for a safety finding', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'possibly already fixed'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/possibly already fixed; it moves to needs human/) }); + expect(store.getTask(identity).status).toBe('possibly already fixed'); + }); + it('keeps task storage before launch when the terminal write fails', async () => { + const { runner, executor, log } = setup({ settleError: true, startError: new Error('docker refused') }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'pending' }); + expect(log.some(line => line.startsWith('release'))).toBe(false); + expect(runner.status(identity).unresolved).toMatchObject({ reason: 'result-not-saved' }); + }); + it('finds a checkpoint by its commit head, and refuses a pause at an unknown snapshot', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const paused = await h.executor.runTask(identity) as { checkpointId: string }; + const store = h.store; + expect(store.checkpointAtHead(identity, oid(100))?.id).toBe(paused.checkpointId); + expect(store.checkpointAtHead(identity, oid(2))).toBeNull(); + expect(() => store.pauseForAmendment(identity, { revision: 1, snapshotId: 'no-such-snapshot' }, { item: 'P1', baseEntries: [], completedItems: ['P1'], outOfScopePaths: ['x'] })) + .toThrow(/Unknown snapshot/); + }); }); From 87e6289da9a8641242a9e33ad1d5233bf0382c61 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:13:55 -0700 Subject: [PATCH 14/26] Fix round 11 of the F2b review: a split rename is exactly one delete and one add - The duplicate rule keeps every entry per folded path and allows a second entry only for a case-only rename reported as exactly one delete and one add with different spellings. Round 10 compared each entry only with the last one, so add, delete, add (two adds of one path) got through. - The runner commit message writes the plan title on one line, so it cannot open a trailer block that forges Plan-Item or Plan-Revision. - Looking for .git reads a backslash as a directory separator (NTFS). - The completed-list comment says what a thrown error carries. - Tests for each, and for an assignment-only change between items, D's underGit flag on its own, task storage removed when the budget is spent at the launch check, and checkpointAtHead finding an older checkpoint. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 25 ++++++++++++----------- runner/execution.ts | 8 ++++++-- test/run-audit.test.ts | 16 +++++++++++++++ test/runner-execution.test.ts | 37 +++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 08aabcb4..6de29bb3 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -41,8 +41,9 @@ const KINDS = new Set(['add', 'modify', 'delete', 'rename', 'mode']); * any case; on NTFS with trailing dots or spaces and as the 8.3 short name `git~1`; on HFS with ignorable code points. */ export function isDotGit(part: string): boolean { - // NTFS ends a name at a stream separator (`:`) or a backslash, then drops trailing dots and spaces. - const name = part.split(/[:\\]/)[0]!; + // NTFS ends a name at a stream separator (`:`), then drops trailing dots and spaces. Callers split on `\` as well + // as `/`, since NTFS reads a backslash as a directory separator. + const name = part.split(':')[0]!; const plain = name.replace(/[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g, '').toLowerCase().replace(/[. ]+$/, ''); return plain === '.git' || plain === 'git~1'; } @@ -91,7 +92,7 @@ function unsafeLinkTarget(linkPath: string, target: string): string | null { // The repository root contains .git: a link to it reaches the metadata through one more path part. if (resolved === '.') return 'target is the repository root'; // As for paths: Git's metadata is `.git` in any case, at any depth. - if (resolved.split('/').some(isDotGit)) return 'target enters .git'; + if (resolved.split(/[/\\]/).some(isDotGit)) return 'target enters .git'; return null; } @@ -111,17 +112,19 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (manifest.nestedGitlinkContent.length) violations.push(`Content appeared under a gitlink: ${list(manifest.nestedGitlinkContent)}.`); const declared = new Set(item.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey)); // Each path appears once, under the trusted path identity: two entries for one path contradict each other. - const seen = new Map(); + const seen = new Map(); for (const change of manifest.changes) { // A case-only rename's two sides are one path under a folding identity; count it once for this change. const keys = new Set([change.path, ...(change.oldPath ? [change.oldPath] : [])].map(pathKey)); for (const key of keys) { - const earlier = seen.get(key); - // A case-only rename that Git reports as a delete and an add (the file also changed a lot) is one path too. - const splitRename = earlier && !earlier.oldPath && !change.oldPath && earlier.path !== change.path - && new Set([earlier.kind, change.kind]).size === 2 && [earlier.kind, change.kind].every(kind => kind === 'add' || kind === 'delete'); - if (earlier && !splitRename) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] }; - seen.set(key, change); + const entries = [...(seen.get(key) ?? []), change]; + seen.set(key, entries); + if (entries.length === 1) continue; + // The only second entry allowed is a case-only rename that Git reports as one delete and one add (the file also + // changed a lot): exactly two entries, one delete and one add, with different spellings. + const [a, b] = entries as [ManifestChange, ManifestChange]; + const splitRename = entries.length === 2 && a.path !== b.path && [a.kind, b.kind].sort().join() === 'add,delete'; + if (!splitRename) return { kind: 'violation', violations: [`The change report lists ${q(key)} more than once.`] }; } } for (const change of manifest.changes) { @@ -130,7 +133,7 @@ export function auditRun(item: PlanItem, manifest: ChangeManifest, pathKey: (pat if (paths.some(path => path !== posix.normalize(path) || path.endsWith('/') || path.startsWith('./'))) { violations.push(`Path not in canonical form in the change report: ${q(change.path)}.`); continue; } // Git refuses a .git part in any spelling it treats as .git, at any depth, so the audit does too. - if (change.underGit || paths.some(path => path.split('/').some(isDotGit))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; } + if (change.underGit || paths.some(path => path.split(/[/\\]/).some(isDotGit))) { violations.push(`The agent changed ${q(change.path)} under .git.`); continue; } if (paths.some(path => path.startsWith('/') || posix.normalize(path).startsWith('../') || ['.', '..'].includes(posix.normalize(path)) || path.includes('\0'))) { violations.push(`Invalid path in the change report: ${q(change.path)}.`); continue; } if (change.oldType === 'gitlink' || change.newType === 'gitlink') { violations.push(`Plan items cannot change gitlinks: ${q(change.path)}.`); continue; } diff --git a/runner/execution.ts b/runner/execution.ts index 94a26034..df90ea46 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -115,7 +115,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag let head: string; try { head = await workspace.commit(data.workspace, { baseHead: data.baseHead, paths, digest: manifest.digest, - message: `${item.id}: ${item.title}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, + // The title is plan text: on one line, it cannot open a trailer block that forges Plan-Item or Plan-Revision. + message: `${item.id}: ${item.title.replace(/[\r\n\u2028\u2029]+/g, ' ').trim()}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); } catch (error) { // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). A stop records its first reason before it @@ -145,7 +146,10 @@ function findIdentity(store: Store, attempt: AttemptRecord): PlanIdentity { return { repositoryId: repositoryId!, taskId: taskId!, planId: planId! }; } -/** `completed` lists the items this run finished before it ended, so a caller never loses them. */ +/** + * `completed` lists the items this run finished before it ended. A thrown error (storage or a bug) carries no list; + * the finished items are still recorded durably, as completed attempts and ledger entries. + */ export type ExecutionOutcome = | { kind: 'executed'; items: string[]; unchanged: string[] } | { kind: 'needs amendment'; item: string; outOfScope: string[]; checkpointId: string; completed: string[] } diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 4227f7c0..767f693c 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -84,6 +84,13 @@ describe('post-run audit', () => { for (const path of ['.git:x', '.git::$INDEX_ALLOCATION/config', 'git~1:s', '.git\\config', 'GIT~1\\hooks', 'a/.g\u200eit', 'a/.g\u202ait', 'a/.g\u206bit', 'a/.gi\u200ft']) expect(auditRun(item, manifest([file(path, { kind: 'add', oldType: undefined })]), exact), path).toMatchObject({ kind: 'violation' }); }); + it('reads a backslash as a directory separator when looking for .git', () => { + for (const path of ['x\\.git\\hooks\\post-checkout', 'a/b\\.GIT']) + expect(auditRun(item, manifest([file(path, { kind: 'add', oldType: undefined })]), exact), path).toMatchObject({ kind: 'violation' }); + }); + it('trusts D\'s underGit flag on its own', () => { + expect(auditRun(item, manifest([file('src/retry.ts', { underGit: true })]), exact)).toEqual({ kind: 'violation', violations: ['The agent changed "src/retry.ts" under .git.'] }); + }); it('refuses a declared link retargeted to the repository root', () => { for (const target of ['.', './', 'a/..']) expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) @@ -105,6 +112,15 @@ describe('post-run audit', () => { // Undeclared, the same pair is a scope finding, not a safety violation; two adds of one folded path are still refused. expect(auditRun(item, manifest([file('notes', { kind: 'delete', newType: undefined }), file('NOTES', { kind: 'add', oldType: undefined })]), folded)).toMatchObject({ kind: 'commit', outOfScope: ['notes', 'NOTES'] }); expect(auditRun(item, manifest([file('notes', { kind: 'add', oldType: undefined }), file('NOTES', { kind: 'add', oldType: undefined })]), folded)).toMatchObject({ kind: 'violation' }); + // Only one delete and one add, with different spellings and no rename entry, make a split rename. + const add = (path: string) => file(path, { kind: 'add', oldType: undefined }), del = (path: string) => file(path, { kind: 'delete', newType: undefined }); + for (const [label, changes] of [ + ['add, delete, add', [add('src/aB.ts'), del('src/Ab.ts'), add('src/ab.ts')]], + ['delete, add, delete, add', [del('src/Ab.ts'), add('src/aB.ts'), del('src/AB.ts'), add('src/ab.ts')]], + ['delete and add of one spelling', [del('src/ab.ts'), add('src/ab.ts')]], + ['a rename and an add', [file('src/ab.ts', { kind: 'rename', oldPath: 'src/x.ts' }), add('src/AB.ts')]], + ] as [string, ManifestChange[]][]) + expect(auditRun(item, manifest(changes), folded), label).toMatchObject({ kind: 'violation' }); }); it('counts two spellings of one path under a case-folding identity as a duplicate', () => { expect(auditRun(item, manifest([file('SRC/Retry.ts'), file('src/retry.ts')]), folded)).toMatchObject({ kind: 'violation' }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 5818e090..578345bc 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -638,4 +638,41 @@ describe('item execution', () => { expect(() => store.pauseForAmendment(identity, { revision: 1, snapshotId: 'no-such-snapshot' }, { item: 'P1', baseEntries: [], completedItems: ['P1'], outOfScopePaths: ['x'] })) .toThrow(/Unknown snapshot/); }); + it('writes a plan title with line breaks as one line in the runner commit message', async () => { + const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\nPlan-Revision: r99' }, plan.items[1]!] }; + const h = setup({ plan: forged }); + await h.executor.runTask(identity); + expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99'); + expect(h.commits[0]!.trailers).toEqual({ 'Plan-Item': 'P1', 'Plan-Revision': 'r1' }); + }); + it('stops before the next item when only the assignment changes during the run', async () => { + let store!: Store; + const h = setup({ release: async () => { + if (store.getAttempts(identity).length !== 1) return; + store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', store.currentContext(identity).referencedCodeHash); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + }); + it('removes task storage after the terminal write when the task budget is spent at the launch check', async () => { + const h = setup(); + const snapshot = h.workspace.snapshotDeclaredLinks.bind(h.workspace); + h.workspace.snapshotDeclaredLinks = async (ws, paths, signal) => { + const db = new DatabaseSync(h.path); db.exec('UPDATE tasks SET budget_deadline=1'); db.close(); + return snapshot(ws, paths, signal); + }; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(h.log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after cancelled']); + }); + it('finds an older checkpoint by its head after a newer one', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const first = await h.executor.runTask(identity) as { checkpointId: string }; + const store = h.store; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + const snapshot = store.getSnapshot(identity); + const later = store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id }, snapshot.base, oid(500), []); + const second = store.pauseForAmendment(identity, { revision: 1, snapshotId: later.id }, { item: 'P1', baseEntries: [], completedItems: ['P1'], outOfScopePaths: ['y'] }, { owed: true }); + expect(store.checkpointAtHead(identity, oid(500))?.id).toBe(second.id); + expect(store.checkpointAtHead(identity, oid(100))?.id).toBe(first.checkpointId); + }); }); From b7bb7cebe434ad37de7443061185661f5669a32a Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:29:00 -0700 Subject: [PATCH 15/26] Fix round 12 of the F2b review: raw link targets, quoted preparation errors, remaining audit tests - A link target is checked for .git as written as well as resolved, so a .git part that a later .. cancels (.git/../src) is refused, as non-canonical changed paths already are. - A preparation error from the workspace is quoted in the diagnostic, like the inspection and commit refusals. - Tests for needs amendment as a human gate, snapshotWithHead picking the latest snapshot, .git behind a backslash in a link target, empty and NUL link targets, a NUL in a path, directory, other and gitlink old entries, the full HFS ignorable ranges, the 300-character quote cut, and an empty digest. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 2 ++ runner/execution.ts | 3 ++- test/run-audit.test.ts | 23 +++++++++++++++++++++++ test/runner-execution.test.ts | 21 ++++++++++++++++++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 6de29bb3..61ec78d4 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -86,6 +86,8 @@ function malformed(manifest: ChangeManifest): string | null { function unsafeLinkTarget(linkPath: string, target: string): string | null { if (!target || target.includes('\0')) return 'empty or invalid target'; if (target.startsWith('/')) return 'absolute target'; + // Checked as written too: a `.git` part that a later `..` cancels (`.git/../src`) still names the metadata on the way. + if (target.split(/[/\\]/).some(isDotGit)) return 'target enters .git'; // A trailing slash names the same directory: `./` and `a/../` are the root, like `.`. const resolved = posix.normalize(posix.join(posix.dirname(linkPath), target)).replace(/\/+$/, '') || '.'; if (resolved === '..' || resolved.startsWith('../')) return 'target leaves the repository'; diff --git a/runner/execution.ts b/runner/execution.ts index df90ea46..1255cb86 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -78,7 +78,8 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot: undefined }; const prepared = { clone: ws.clone, vendor, approvedArgv: request.approvedArgv, private: data }; try { data.linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredPaths, signal); } - catch (error) { throw new PreparationFailure(error, prepared); } + // D's text can name paths an earlier item's agent created: quote it (AGENTS.md). + catch (error) { throw new PreparationFailure(new Error(JSON.stringify(error instanceof Error ? error.message : String(error)), { cause: error }), prepared); } return prepared; }, async cleanupPreparation() { /* host-side files belong to D's materialize; task storage waits for release */ }, diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 767f693c..bd8ff4c3 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -88,6 +88,29 @@ describe('post-run audit', () => { for (const path of ['x\\.git\\hooks\\post-checkout', 'a/b\\.GIT']) expect(auditRun(item, manifest([file(path, { kind: 'add', oldType: undefined })]), exact), path).toMatchObject({ kind: 'violation' }); }); + it('refuses bad link targets: .git behind a backslash or cancelled by .., empty, or with a NUL', () => { + const link = (target: string) => manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]); + expect(auditRun(item, link('sub\\.git\\config'), exact)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); + expect(auditRun(item, link('.git/../src/retry.ts'), exact)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); + for (const target of ['', 'src/re\0try.ts']) + expect(auditRun(item, link(target), exact), JSON.stringify(target)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": empty or invalid target.'] }); + }); + it('refuses a NUL in a path, and a directory, other or gitlink old entry', () => { + expect(auditRun(item, manifest([file('src/re\0try.ts')]), exact)).toMatchObject({ kind: 'violation' }); + expect(auditRun(item, manifest([file('build', { kind: 'delete', oldType: 'directory', newType: undefined })]), exact)) + .toEqual({ kind: 'violation', violations: ['Unexpected directory entry: "build".'] }); + expect(auditRun(item, manifest([file('fifo', { kind: 'delete', oldType: 'other', newType: undefined })]), exact)) + .toEqual({ kind: 'violation', violations: ['Unexpected other entry: "fifo".'] }); + expect(auditRun(item, manifest([file('vendor/lib', { kind: 'delete', oldType: 'gitlink', newType: undefined })]), exact)) + .toEqual({ kind: 'violation', violations: ['Plan items cannot change gitlinks: "vendor/lib".'] }); + }); + it('covers the whole HFS ignorable ranges, and cuts a quoted path at 300 characters', () => { + for (const mark of ['\u200c', '\u200f', '\u202a', '\u202e', '\u206a', '\u206f', '\ufeff']) + expect(auditRun(item, manifest([file(`a/.gi${mark}t`, { kind: 'add', oldType: undefined })]), exact), JSON.stringify(mark)).toMatchObject({ kind: 'violation' }); + const long = `${'x'.repeat(400)}/.git`; + const outcome = auditRun(item, manifest([file(long, { kind: 'add', oldType: undefined })]), exact) as { violations: string[] }; + expect(outcome.violations[0]).toBe(`The agent changed ${JSON.stringify(`${long.slice(0, 300)}…`)} under .git.`); + }); it('trusts D\'s underGit flag on its own', () => { expect(auditRun(item, manifest([file('src/retry.ts', { underGit: true })]), exact)).toEqual({ kind: 'violation', violations: ['The agent changed "src/retry.ts" under .git.'] }); }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 578345bc..597bf656 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -178,7 +178,7 @@ describe('item execution', () => { }); it('removes task storage after the terminal write when preparation fails after allocating it', async () => { const { store, runner, executor, log } = setup({ snapshotError: new Error('declared link goes through a link') }); - expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Preparation failed: declared link goes through a link' }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'Preparation failed: "declared link goes through a link"' }); expect(log).toEqual(['materialize P1 @002', 'snapshot P1 [a.ts]', 'release P1 after failed']); expect(runner.status(identity).unresolved).toBeNull(); expect(store.getTask(identity).status).toBe('running'); @@ -675,4 +675,23 @@ describe('item execution', () => { expect(store.checkpointAtHead(identity, oid(500))?.id).toBe(second.id); expect(store.checkpointAtHead(identity, oid(100))?.id).toBe(first.checkpointId); }); + it('keeps needs amendment as a human gate for a safety finding', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs amendment'); } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/needs amendment; it moves to needs human/) }); + expect(store.getTask(identity).status).toBe('needs amendment'); + }); + it('picks the latest snapshot with a head', () => { + const { store } = setup(); + const first = store.getSnapshot(identity); + const other = store.recordHistory(identity, { revision: 1, snapshotId: first.id }, first.base, oid(300), []); + const again = store.recordHistory(identity, { revision: 1, snapshotId: other.id }, first.base, first.head, []); + expect(store.snapshotWithHead(identity, first.head)).toBe(again.id); + }); + it('sends a change report with an empty digest to needs human', async () => { + const { executor } = setup({ manifests: { P1: { ...manifest([change('a.ts')]), digest: '' } } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', reason: `${SAFETY_VIOLATION} The change report has no digest.` }); + }); }); From dd1a837a3912f5a77cfabeabe2ef9b4f8109c1e8 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:44:02 -0700 Subject: [PATCH 16/26] Fix round 13 of the F2b review: quote every preparation error, test shutdown between items - The coordinator quotes every preparation error in the diagnostic, so a materialize error that names an agent-chosen path cannot forge a second line. Round 12 had quoted only the declared-link snapshot's errors. - Tests for runTask returning stopped with the completed items when shutdown refuses the next item's admission, and for a pause's executed prefix being built from the plan the item ran against when a revision inserts an item before it. The existing revision-during-release test now asserts that the revision really changed, since a failed import in release is absorbed. Co-Authored-By: Claude Opus 5.5 --- runner/coordinator.ts | 3 ++- runner/execution.ts | 3 +-- test/runner-coordinator.test.ts | 4 ++-- test/runner-execution.test.ts | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/runner/coordinator.ts b/runner/coordinator.ts index 77d0d1ee..48660348 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -358,7 +358,8 @@ export class RunnerCoordinator { #preparationDetail(job: Job, error?: unknown): { detail?: string } { // D never ran, so there is no D stop reason; without one the Store keeps this text instead of "Timed out.". if (job.preparationTimedOut && !job.firstReason) return { detail: PREPARATION_TIMEOUT }; - return error === undefined || job.firstReason ? {} : { detail: `Preparation failed: ${message(error)}` }; + // Preparation errors can name repository paths an agent chose (an earlier item's files): quote them (AGENTS.md). + return error === undefined || job.firstReason ? {} : { detail: `Preparation failed: ${JSON.stringify(message(error))}` }; } /** Ending without a handle: host-side cleanup, then the terminal write from the first reason. */ async #endBeforeLaunch(job: Job, attempt: AttemptRecord, s: { detail?: string }, prepared?: PreparedAttempt): Promise { diff --git a/runner/execution.ts b/runner/execution.ts index 1255cb86..df90ea46 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -78,8 +78,7 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag const data: Private = { workspace: ws, prompt: request.prompt, baseHead, linkSnapshot: undefined }; const prepared = { clone: ws.clone, vendor, approvedArgv: request.approvedArgv, private: data }; try { data.linkSnapshot = await workspace.snapshotDeclaredLinks(ws, declaredPaths, signal); } - // D's text can name paths an earlier item's agent created: quote it (AGENTS.md). - catch (error) { throw new PreparationFailure(new Error(JSON.stringify(error instanceof Error ? error.message : String(error)), { cause: error }), prepared); } + catch (error) { throw new PreparationFailure(error, prepared); } return prepared; }, async cleanupPreparation() { /* host-side files belong to D's materialize; task storage waits for release */ }, diff --git a/test/runner-coordinator.test.ts b/test/runner-coordinator.test.ts index 07d715f3..cf9d9431 100644 --- a/test/runner-coordinator.test.ts +++ b/test/runner-coordinator.test.ts @@ -180,7 +180,7 @@ describe('stops and settlement', () => { await runner.settled(A); expect(launches).toHaveLength(0); expect(cleaned()).toBe(1); - expect(store.getAttempt(A, attempt.id)).toMatchObject({ state: 'failed', firstReason: null, diagnostic: 'Preparation failed: clone failed' }); + expect(store.getAttempt(A, attempt.id)).toMatchObject({ state: 'failed', firstReason: null, diagnostic: 'Preparation failed: "clone failed"' }); expect(() => runner.start(B, request(store, B))).not.toThrow(); }); it('fails with the launch error when D start throws and no stop is recorded', async () => { @@ -556,7 +556,7 @@ describe('copilot review', () => { preparations[0]!.reject(new Error('clone failed')); await runner.settled(A); expect(launches).toHaveLength(0); - expect(store.getAttempt(A, attempt.id)).toMatchObject({ state: 'failed', diagnostic: 'Preparation failed: clone failed' }); + expect(store.getAttempt(A, attempt.id)).toMatchObject({ state: 'failed', diagnostic: 'Preparation failed: "clone failed"' }); expect(runner.status(A).unresolved).toEqual({ attemptId: attempt.id, reason: 'preparation-not-removed' }); expect(() => runner.start(B, request(store, B))).toThrow(/No free runner slot/); }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 597bf656..240da243 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -29,7 +29,7 @@ const manifest = (changes: ManifestChange[], over: Partial = {}) function setup(options: { manifests?: Record; exit?: Record>; commit?: (item: string) => Promise; release?: () => Promise; startError?: Error; inspect?: (item: string, signal: AbortSignal) => Promise; snapshotError?: Error; capability?: (store: Store) => ShutdownCapability; settleError?: boolean; - plan?: Plan; commitHead?: string; pathKeyError?: Error } = {}) { + plan?: Plan; commitHead?: string; pathKeyError?: Error; materializeError?: Error } = {}) { const dir = mkdtempSync(join(tmpdir(), 'codeboost-exec-')); dirs.push(dir); const path = join(dir, 'state.sqlite'), store = new Store(path); store.createPlan(JSON.stringify(options.plan ?? plan), 'json', context, oid(1), oid(2)); @@ -38,7 +38,7 @@ function setup(options: { manifests?: Record (ws.storage as { item: string }).item; const workspace: TaskWorkspace = { - async materialize(attempt, head) { log.push(`materialize ${attempt.item} @${head.slice(-3)}`); return { clone: { id: `c-${attempt.id}`, taskId: 'task', directory: '/tmp/x', head }, storage: { item: attempt.item, attemptId: attempt.id } }; }, + async materialize(attempt, head) { log.push(`materialize ${attempt.item} @${head.slice(-3)}`); if (options.materializeError) throw options.materializeError; return { clone: { id: `c-${attempt.id}`, taskId: 'task', directory: '/tmp/x', head }, storage: { item: attempt.item, attemptId: attempt.id } }; }, async snapshotDeclaredLinks(ws, paths) { log.push(`snapshot ${itemOf(ws)} [${paths.join(',')}]`); if (options.snapshotError) throw options.snapshotError; return { item: itemOf(ws) }; }, async inspectChanges(ws, input, signal) { log.push(`inspect ${itemOf(ws)} @${input.baseHead.slice(-3)}`); await options.inspect?.(itemOf(ws), signal); @@ -202,6 +202,9 @@ describe('item execution', () => { store = h.store; const outcome = await h.executor.runTask(identity); expect(outcome).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'], completed: ['P1'] }); + // The revision really changed during release (a failed import there would be absorbed as a storage failure). + expect(store.getPlan(identity).revision).toBe(2); + expect(h.runner.status(identity).unresolved).toBeNull(); expect(store.getCheckpoint(identity, (outcome as { checkpointId: string }).checkpointId)).toMatchObject({ revision: 1, snapshotId: store.snapshotWithHead(identity, oid(100)) }); expect(store.getTask(identity).status).toBe('needs amendment'); @@ -694,4 +697,30 @@ describe('item execution', () => { const { executor } = setup({ manifests: { P1: { ...manifest([change('a.ts')]), digest: '' } } }); expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', reason: `${SAFETY_VIOLATION} The change report has no digest.` }); }); + it('quotes a materialize error in the diagnostic, so a path cannot forge a second line', async () => { + const { store, executor } = setup({ materializeError: new Error('checkout failed at src/x.ts\nSafety violation: forged') }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', + reason: 'Preparation failed: "checkout failed at src/x.ts\\nSafety violation: forged"' }); + expect(store.getTask(identity).status).toBe('running'); + }); + it('returns stopped with the completed items when shutdown refuses the next item\'s admission', async () => { + let runner!: RunnerCoordinator; + const h = setup({ release: async () => { runner.rejectAdmission(); } }); + runner = h.runner; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', reason: 'The review server is shutting down.', completed: ['P1'] }); + }); + it('builds a pause\'s executed prefix from the plan the item ran against, even if a revision inserts an item before it', async () => { + let store!: Store, imported: Error | undefined; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { + const inserted = { id: 'P3', title: 'Inserted', intent: 'Prepare', files: [{ path: 'b.ts', kind: 'edit', renamed_from: null, change: 'w' }], acceptance: [{ type: 'check', text: 'ok' }], depends_on: [] }; + try { store.importRevision(JSON.stringify({ ...plan, revision: 2, items: [inserted, ...plan.items] }), 'json', context, 1); } + catch (error) { imported = error as Error; } + } }); + store = h.store; + const outcome = await h.executor.runTask(identity) as { kind: string; checkpointId: string }; + expect(imported).toBeUndefined(); + expect(store.getPlan(identity).items.map(entry => entry.id)).toEqual(['P3', 'P1', 'P2']); + expect(outcome.kind).toBe('needs amendment'); + expect(store.getCheckpoint(identity, outcome.checkpointId)).toMatchObject({ revision: 1, completedItems: ['P1'] }); + }); }); From 50993bea305f1cd4098dbd970408bc44374d2f45 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 11:56:40 -0700 Subject: [PATCH 17/26] Fix round 14 of the F2b review: quote agent stderr, harden release-hook tests - The agent's stderr is quoted in the attempt diagnostic, so it cannot forge a runner line such as "Safety violation:" in the stopped reason. Round 13 quoted preparation errors for the same threat. - Tests that set up a change inside the release hook now also assert that no storage marker was left and the stop names the change, so a failing setup there cannot make them pass vacuously. - Tests for a rename with both sides undeclared and for an AbortError from the inspection with no stop pending. Co-Authored-By: Claude Opus 5.5 --- runner/coordinator.ts | 3 ++- test/run-audit.test.ts | 2 ++ test/runner-execution.test.ts | 27 ++++++++++++++++++++++----- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/runner/coordinator.ts b/runner/coordinator.ts index 48660348..203d809b 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -325,7 +325,8 @@ export class RunnerCoordinator { if (foreignSaved && !(await this.#removePreparation(job, attempt))) this.#holdForPreparation(job); return; } - let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? bounded(result.stderr) : undefined; + // The agent's stderr is its own text: quote it (AGENTS.md), so it cannot forge a runner line in the diagnostic. + let valid = false, value: unknown, history: HistoryRecord | undefined, detail = result.stderr ? JSON.stringify(bounded(result.stderr)) : undefined; if (!job.firstReason && result.exitCode === 0 && !result.stopReason) { try { if (this.#deps.finish) { const done = await this.#deps.finish(attempt, result, prepared, job.controller.signal); value = done.value; history = done.history; } diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index bd8ff4c3..31cc39c3 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -18,6 +18,8 @@ describe('post-run audit', () => { .toEqual({ kind: 'commit', inScope: ['src/retry.ts', 'docs/New.md'], outOfScope: [], unchanged: false, needsAmendment: false }); expect(auditRun(item, manifest([file('src/retry.ts'), file('src/extra.ts', { kind: 'add', oldType: undefined })]), exact)) .toMatchObject({ kind: 'commit', outOfScope: ['src/extra.ts'], needsAmendment: true }); + // Both sides of a rename are findings when neither is declared. + expect(auditRun(item, manifest([file('x.ts', { kind: 'rename', oldPath: 'y.ts' })]), exact)).toMatchObject({ kind: 'commit', outOfScope: ['x.ts', 'y.ts'] }); // The undeclared source is the finding, not the declared destination. expect(auditRun(item, manifest([file('docs/New.md', { kind: 'rename', oldPath: 'docs/unlisted.md' })]), exact)) .toMatchObject({ kind: 'commit', inScope: [], outOfScope: ['docs/unlisted.md'] }); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 240da243..2a6568bd 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -114,7 +114,7 @@ describe('item execution', () => { }); it('stops on an agent failure without inspecting or committing', async () => { const { store, executor, log } = setup({ exit: { P1: { exitCode: 1, stderr: 'agent crashed' } } }); - expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: 'agent crashed' }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'failed', reason: '"agent crashed"' }); expect(log.some(line => line.startsWith('inspect'))).toBe(false); expect(store.getSnapshot(identity).head).toBe(oid(2)); }); @@ -191,8 +191,9 @@ describe('item execution', () => { store.importRevision(JSON.stringify(revised), 'json', context, 1); } }); store = h.store; - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'], reason: expect.stringMatching(/new revision/) }); expect(h.commits.map(c => c.item)).toEqual(['P1']); + expect(h.runner.status(identity).unresolved).toBeNull(); }); it('still pauses for amendment, bound to where the item ran, when the plan changes after its attempt settled', async () => { let store!: Store; @@ -306,13 +307,15 @@ describe('item execution', () => { store = h.store; expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); expect(h.commits.map(c => c.item)).toEqual(['P1']); + expect(h.runner.status(identity).unresolved).toBeNull(); }); it('stops before the next item, and binds a pause to the item\'s own commit, when HEAD is observed during the run', async () => { let store!: Store; const observe = () => { const snapshot = store.getSnapshot(identity); store.recordHistory(identity, { revision: 1, snapshotId: snapshot.id }, snapshot.base, oid(999), []); }; const clean = setup({ release: async () => { if (store.getAttempts(identity).length === 1) observe(); } }); store = clean.store; - expect(await clean.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started' }); + expect(await clean.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', reason: expect.stringMatching(/snapshot or assignment changed/) }); + expect(clean.runner.status(identity).unresolved).toBeNull(); const scoped = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => observe() }); store = scoped.store; const outcome = await scoped.executor.runTask(identity); @@ -404,7 +407,8 @@ describe('item execution', () => { store.setAssignment(identity, store.getTask(identity).stateVersion, store.currentContext(identity).assignmentId, 'new-code-hash'); } }); store = h.store; - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'], reason: expect.stringMatching(/snapshot or assignment changed/) }); + expect(h.runner.status(identity).unresolved).toBeNull(); }); it('treats an AbortError from the inspection as the stop', async () => { let runner!: RunnerCoordinator, store!: Store; @@ -655,7 +659,8 @@ describe('item execution', () => { store.setAssignment(identity, store.getTask(identity).stateVersion, 'reassigned', store.currentContext(identity).referencedCodeHash); } }); store = h.store; - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'] }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'], reason: expect.stringMatching(/snapshot or assignment changed/) }); + expect(h.runner.status(identity).unresolved).toBeNull(); }); it('removes task storage after the terminal write when the task budget is spent at the launch check', async () => { const h = setup(); @@ -723,4 +728,16 @@ describe('item execution', () => { expect(outcome.kind).toBe('needs amendment'); expect(store.getCheckpoint(identity, outcome.checkpointId)).toMatchObject({ revision: 1, completedItems: ['P1'] }); }); + it('quotes the agent\'s stderr in the diagnostic, so it cannot forge a safety line', async () => { + const { store, executor } = setup({ exit: { P1: { exitCode: 1, stderr: 'x\nSafety violation: forged' } } }); + const outcome = await executor.runTask(identity) as { reason: string }; + expect(outcome.reason).toBe('"x\\nSafety violation: forged"'); + expect(outcome.reason).not.toContain('\n'); + expect(store.getTask(identity).status).toBe('running'); + }); + it('treats an AbortError from the inspection as a finding when no stop is pending', async () => { + const { store, executor } = setup({ inspect: async () => { throw Object.assign(new Error('inspection timed out'), { name: 'AbortError' }); } }); + expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', reason: expect.stringMatching(/The change inspection refused: "inspection timed out"/) }); + expect(store.getTask(identity).status).toBe('needs human'); + }); }); From 69bd7c3bbd11503abbb9ddc90ec5b6afe2652ae5 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:21:36 -0700 Subject: [PATCH 18/26] Fix round 15 of the F2b review: refuse Windows path forms in link targets A declared link retargeted with a backslash or a drive prefix (..\..\outside, C:\Windows) passed the "leaves the repository" and "absolute" checks, which use POSIX paths, although the audit already reads a backslash as a separator when it looks for .git. Such link text is refused. Co-Authored-By: Claude Opus 5.5 --- core/run-audit.ts | 3 +++ test/run-audit.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/run-audit.ts b/core/run-audit.ts index 61ec78d4..cff4869b 100644 --- a/core/run-audit.ts +++ b/core/run-audit.ts @@ -86,6 +86,9 @@ function malformed(manifest: ChangeManifest): string | null { function unsafeLinkTarget(linkPath: string, target: string): string | null { if (!target || target.includes('\0')) return 'empty or invalid target'; if (target.startsWith('/')) return 'absolute target'; + // A backslash or a drive prefix would be a separator or an absolute path on NTFS, where the audit's other checks + // (which use POSIX paths) could not see an escape: link text in this repository is POSIX-only. + if (target.includes('\\') || /^[A-Za-z]:/.test(target)) return 'target uses a Windows path form'; // Checked as written too: a `.git` part that a later `..` cancels (`.git/../src`) still names the metadata on the way. if (target.split(/[/\\]/).some(isDotGit)) return 'target enters .git'; // A trailing slash names the same directory: `./` and `a/../` are the root, like `.`. diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index 31cc39c3..a6a72af0 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -90,9 +90,10 @@ describe('post-run audit', () => { for (const path of ['x\\.git\\hooks\\post-checkout', 'a/b\\.GIT']) expect(auditRun(item, manifest([file(path, { kind: 'add', oldType: undefined })]), exact), path).toMatchObject({ kind: 'violation' }); }); - it('refuses bad link targets: .git behind a backslash or cancelled by .., empty, or with a NUL', () => { + it('refuses bad link targets: a Windows path form, .git cancelled by .., empty, or with a NUL', () => { const link = (target: string) => manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]); - expect(auditRun(item, link('sub\\.git\\config'), exact)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); + for (const target of ['sub\\.git\\config', '..\\..\\outside', 'C:\\Windows', 'c:outside']) + expect(auditRun(item, link(target), exact), target).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target uses a Windows path form.'] }); expect(auditRun(item, link('.git/../src/retry.ts'), exact)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target enters .git.'] }); for (const target of ['', 'src/re\0try.ts']) expect(auditRun(item, link(target), exact), JSON.stringify(target)).toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": empty or invalid target.'] }); From ebd25c65601a4d5ef3cc591b4576203547264d1b Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:36:06 -0700 Subject: [PATCH 19/26] Fix round 16 of the F2b review: a new run's start settles nothing of a run still finishing - runTask refuses to start while an earlier run of the task still has an active job (its storage release), so a second run cannot pay the first run's scope pause or safety finding and change that run's outcome. - Paying an owed finding or pause at the start of a run is that run's own decision, not settlement: it writes without the shutdown capability, and a closed write gate leaves it owed. - A finding whose terminal write failed reports the needs-restart cause and stays owed, instead of "An attempt is still active". - The coordinator's log lines quote D's error text. - Tests for each, and for a stop that lands before a rejected commit. Co-Authored-By: Claude Opus 5.5 --- runner/coordinator.ts | 6 +++--- runner/execution.ts | 28 +++++++++++++++++------- test/runner-execution.test.ts | 40 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/runner/coordinator.ts b/runner/coordinator.ts index 203d809b..e35c149f 100644 --- a/runner/coordinator.ts +++ b/runner/coordinator.ts @@ -379,7 +379,7 @@ export class RunnerCoordinator { async #removePreparation(job: Job, attempt: AttemptRecord): Promise { try { await this.#deps.cleanupPreparation(attempt); return true; } catch (error) { - console.error(`Runner job ${job.attemptId} could not remove its preparation files: ${message(error)}`); + console.error(`Runner job ${job.attemptId} could not remove its preparation files: ${JSON.stringify(message(error))}`); return false; } } @@ -392,7 +392,7 @@ export class RunnerCoordinator { if (!this.#deps.release) return; try { await this.#deps.release(attempt, prepared); } catch (error) { - console.error(`Runner job ${job.attemptId} could not remove its task storage: ${message(error)}`); + console.error(`Runner job ${job.attemptId} could not remove its task storage: ${JSON.stringify(message(error))}`); if (!this.#markers.has(job.key)) this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'storage-not-removed' }); } } @@ -408,7 +408,7 @@ export class RunnerCoordinator { #unexpected(job: Job, error: unknown): void { // Fail closed: an unexpected error keeps the slot held until restart. this.#markers.set(job.key, { group: job.group, attemptId: job.attemptId, reason: 'result-not-saved' }); - console.error(`Runner job ${job.attemptId} failed unexpectedly: ${message(error)}`); + console.error(`Runner job ${job.attemptId} failed unexpectedly: ${JSON.stringify(message(error))}`); } } const message = (error: unknown) => bounded(error instanceof Error ? error.message : String(error)); diff --git a/runner/execution.ts b/runner/execution.ts index df90ea46..9ca53a1d 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -156,6 +156,8 @@ export type ExecutionOutcome = | { kind: 'needs human'; item: string; reason: string; completed: string[] } | { kind: 'stopped'; item: string; state: string; reason: string | null; completed: string[] }; const refusal = (error: unknown) => error instanceof GuardRefusal || error instanceof ShuttingDownError; +/** A write outside settlement: no shutdown capability. */ +const direct = (fn: () => T): T => fn(); /** * Statuses that wait for a person; leaving one needs its own user action (runner-lifecycle.md), so a safety finding is * owed there instead. Review statuses are not gates: a finding moves them to needs human, so the task cannot be merged. @@ -183,10 +185,13 @@ export class ItemExecutor { if (start < 0) throw new Error('Unknown plan item.'); const done: string[] = [], unchanged: string[] = []; const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); + // An earlier run of this task that is still finishing (its storage release) settles its own findings and pause. + if (this.#runner.isActive(identity)) + return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', 'An earlier run of this task is still finishing; start it again when that run has ended.'); // A safety finding not yet acted on (a failed write, a human gate at the time) goes to needs human first. for (const earlier of this.#store.getAttempts(identity)) { const finding = this.#findings.get(earlier.id); - if (finding) return this.#escalate(identity, earlier, finding, stopped, []); + if (finding) return this.#escalate(identity, earlier, finding, stopped, [], true); } // A scope finding whose pause was never recorded (a failed write, the write gate, a crash) pauses now, before any item. const owed = this.#unpausedScopeFinding(identity); @@ -247,8 +252,13 @@ export class ItemExecutor { * The finding is settled only once acted on, so a failed write leaves it owed too. */ #escalate(identity: PlanIdentity, row: AttemptRecord, violation: string, - stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[]): ExecutionOutcome { + stopped: (item: string, state: string, reason: string | null) => ExecutionOutcome, done: string[], owed = false): ExecutionOutcome { const item = row.item!, task = this.#store.getTask(identity); + // The terminal write failed: the attempt still counts as active, so the move waits for restart; keep the finding owed. + if (row.state === 'pending' || row.state === 'running') { + const unresolved = this.#runner.status(identity).unresolved; + return stopped(item, row.state, `${violation} ${unresolved ? NEEDS_RESTART[unresolved.reason] : 'Needs restart: the attempt\'s outcome could not be saved.'}`); + } if (CLOSED_STATUSES.includes(task.status)) { this.#findings.settle(row.id); return stopped(item, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); @@ -260,10 +270,12 @@ export class ItemExecutor { } if (HUMAN_GATES.includes(task.status)) return stopped(item, row.state, `${violation} The task is ${task.status}; it moves to needs human when it next runs.`); - try { this.#write(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } + // Settling this run's own attempt writes through the shutdown capability; paying an owed finding at the start of a + // new run is that run's decision, so it does not, and the closed write gate refuses it like any other. + try { (owed ? direct : this.#write)(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } catch (error) { - if (!(error instanceof GuardRefusal)) throw error; - return stopped(item, row.state, `${violation} The task could not be moved to needs human yet: ${error.message}`); + if (!(error instanceof GuardRefusal) && !(owed && error instanceof ShuttingDownError)) throw error; + return stopped(item, row.state, `${violation} The task could not be moved to needs human yet: ${(error as Error).message}`); } this.#findings.settle(row.id); return { kind: 'needs human', item, reason: violation, completed: [...done] }; @@ -288,13 +300,13 @@ export class ItemExecutor { const items = this.#store.getPlan(identity, row.context.planRevision).items; let checkpointId: string; try { - checkpointId = this.#write(() => this.#store.pauseForAmendment(identity, { revision: row.context.planRevision, snapshotId }, { + checkpointId = (owed ? direct : this.#write)(() => this.#store.pauseForAmendment(identity, { revision: row.context.planRevision, snapshotId }, { item, baseEntries: this.#sources.planContext(identity).baseEntries, completedItems: items.slice(0, items.findIndex(entry => entry.id === item) + 1).map(entry => entry.id), outOfScopePaths: result.outOfScope, }, { owed })).id; } catch (error) { - if (!(error instanceof GuardRefusal)) throw error; - return stopped(item, row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${error.message}`); + if (!(error instanceof GuardRefusal) && !(owed && error instanceof ShuttingDownError)) throw error; + return stopped(item, row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${(error as Error).message}`); } return { kind: 'needs amendment', item, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; } diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 2a6568bd..3c0a91e7 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -740,4 +740,44 @@ describe('item execution', () => { expect(await executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1', reason: expect.stringMatching(/The change inspection refused: "inspection timed out"/) }); expect(store.getTask(identity).status).toBe('needs human'); }); + it('does not pay an owed finding through the shutdown capability once the write gate has closed', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, capability: s => s.shutdownCapability(), + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + await h.executor.runTask(identity); + const attemptId = store.getAttempts(identity)[0]!.id; + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + store.closeWrites(); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/could not be moved to needs human yet: The review server is shutting down/) }); + expect(store.getTask(identity).status).toBe('queued'); + expect(h.findings.get(attemptId)).toBeDefined(); + }); + it('leaves a run that is still releasing to settle its own scope pause and finding', async () => { + for (const unsafe of [false, true]) { + let executor!: ItemExecutor, second: Promise | undefined; + const h = setup({ manifests: { P1: unsafe ? manifest([change('a.ts')], { metadataChanged: true }) : manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, + release: async () => { second = executor.runTask(identity); await second; } }); + executor = h.executor; + expect(await h.executor.runTask(identity), String(unsafe)).toMatchObject({ kind: unsafe ? 'needs human' : 'needs amendment', item: 'P1' }); + expect(await second, String(unsafe)).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/still finishing/) }); + } + }); + it('reports the needs-restart cause, and keeps the finding owed, when a finding\'s terminal write failed', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, settleError: true }); + const outcome = await h.executor.runTask(identity) as { kind: string; reason: string }; + expect(outcome.kind).toBe('stopped'); + expect(outcome.reason).toMatch(/Needs restart: the last result could not be saved\./); + expect(h.findings.get(h.store.getAttempts(identity)[0]!.id)).toBeDefined(); + }); + it('ends as the stop, not a refused commit, when a stop lands and the commit then rejects', async () => { + let runner!: RunnerCoordinator, store!: Store; + const h = setup({ commit: async () => { + runner.stop(identity, store.getTask(identity).currentAttemptId!, 'cancelled'); + throw new Error('commit aborted'); + } }); + runner = h.runner; store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); + expect(store.getLedger(identity)).toEqual([]); + }); }); From d503def41cd254e72ac9911eb2c13a202792a5d3 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 13:50:52 -0700 Subject: [PATCH 20/26] Fix round 17 of the F2b review: find every owed scope pause, cover the remaining claims - The owed-pause check looks at every completed execute attempt, not only the latest, so a later clean attempt cannot hide an earlier finding whose pause was never recorded. - Tests for an owed scope pause left owed (not paid through the shutdown capability) once the write gate closed, the ledger record's base, and the coordinator's log lines quoting D's error text. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 13 ++++++---- test/runner-execution.test.ts | 47 ++++++++++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/runner/execution.ts b/runner/execution.ts index 9ca53a1d..3a2097f2 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -280,12 +280,15 @@ export class ItemExecutor { this.#findings.settle(row.id); return { kind: 'needs human', item, reason: violation, completed: [...done] }; } - /** The latest completed execute attempt, if its out-of-scope files have no checkpoint yet (its pause was lost). */ + /** The earliest completed execute attempt whose out-of-scope files have no checkpoint yet (its pause was lost). */ #unpausedScopeFinding(identity: PlanIdentity): { row: AttemptRecord; result: ExecutionResult } | null { - const row = this.#store.getAttempts(identity).filter(entry => entry.kind === 'execute' && entry.state === 'completed').at(-1); - const result = row?.result as ExecutionResult | undefined; - if (!row || !result?.outOfScope?.length) return null; - return this.#store.checkpointAtHead(identity, result.head) ? null : { row, result }; + // Every completed execute attempt, not only the latest: a later clean one must not hide an earlier owed pause. + for (const row of this.#store.getAttempts(identity)) { + const result = row.result as ExecutionResult | undefined; + if (row.kind !== 'execute' || row.state !== 'completed' || !result?.outOfScope?.length) continue; + if (!this.#store.checkpointAtHead(identity, result.head)) return { row, result }; + } + return null; } /** * The scope pause. The checkpoint names the revision the item ran against and the snapshot its own commit created, so diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 3c0a91e7..c2da4c65 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { DatabaseSync } from 'node:sqlite'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; import { RunnerCoordinator } from '../runner/coordinator.ts'; import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; @@ -780,4 +780,49 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'cancelled' }); expect(store.getLedger(identity)).toEqual([]); }); + it('does not pay an owed scope pause through the shutdown capability once the write gate has closed', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, capability: s => s.shutdownCapability(), + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + await h.executor.runTask(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + store.closeWrites(); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/could not pause for amendment: The review server is shutting down/) }); + expect(store.getTask(identity).status).toBe('queued'); + expect(store.latestCheckpoint(identity)).toBeNull(); + }); + it('records the snapshot\'s base, not the item\'s base head, in the ledger record', async () => { + const h = setup(); + await h.executor.runTask(identity); + const snapshot = h.store.getSnapshot(identity); + expect(snapshot.head).toBe(oid(101)); + expect(snapshot.base).toBe(oid(1)); + }); + it('quotes D\'s error text in the coordinator\'s log lines', async () => { + const lines: string[] = []; + const spy = vi.spyOn(console, 'error').mockImplementation((line: unknown) => { lines.push(String(line)); }); + try { + const h = setup({ release: async () => { throw new Error('rm failed\nRunner job forged: ok'); } }); + await h.executor.runTask(identity); + } finally { spy.mockRestore(); } + expect(lines.some(line => line.endsWith('could not remove its task storage: "rm failed\\nRunner job forged: ok"'))).toBe(true); + expect(lines.every(line => !line.includes('\n'))).toBe(true); + }); + it('finds an owed scope pause even when a later clean item completed after it', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const store = h.store; + // Record P1's out-of-scope result as if its pause had been lost, then let a clean P2 complete after it. + const pause = store.pauseForAmendment.bind(store); + store.pauseForAmendment = () => { throw Object.assign(new Error('disk full'), { code: 'ERR_SQLITE_ERROR' }); }; + await expect(h.executor.runTask(identity)).rejects.toThrow(/disk full/); + store.pauseForAmendment = pause; + const p2 = h.runner.start(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P2', + expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + await h.runner.settled(identity); + expect(store.getAttempt(identity, p2.id).state).toBe('completed'); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs amendment', item: 'P1', outOfScope: ['extra.ts'] }); + // Paid from the durable result: no item ran again. + expect(store.getAttempts(identity)).toHaveLength(2); + }); }); From f58f2ccc4f8197b0c9ae22a1cde49345e6e88489 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:08:35 -0700 Subject: [PATCH 21/26] Fix round 18 of the F2b review: compare the whole context between items, stop after shutdown began - Between items the executor compares the whole context with what the previous item left: its commit's snapshot and a context generation raised by exactly that commit. A change that only bumps the generation (for example a same-valued reassignment) now stops the run too. - A run started after shutdown began pays nothing owed and starts nothing. - The needs-human settle test checks that no extra write happened. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 11 +++++++---- test/runner-execution.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/runner/execution.ts b/runner/execution.ts index 3a2097f2..6fcade0b 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -1,6 +1,6 @@ import type { PlanIdentity } from '../core/identity.ts'; import type { PlanContext } from '../core/plan.ts'; -import type { InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; +import type { InvocationContext, InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; import { prepareExecution } from '../core/execution-prompt.ts'; import { auditRun, type ChangeManifest } from '../core/run-audit.ts'; import { FinishFailure, NEEDS_RESTART, PreparationFailure, type PreparedAttempt, type RunnerCoordinator, type RunnerDeps } from './coordinator.ts'; @@ -185,6 +185,8 @@ export class ItemExecutor { if (start < 0) throw new Error('Unknown plan item.'); const done: string[] = [], unchanged: string[] = []; const stopped = (item: string, state: string, reason: string | null): ExecutionOutcome => ({ kind: 'stopped', item, state, reason, completed: [...done] }); + // Shutdown began (admission is closed): pay nothing owed and start nothing; the next run after restart does. + if (this.#runner.closing) return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', 'The review server is shutting down.'); // An earlier run of this task that is still finishing (its storage release) settles its own findings and pause. if (this.#runner.isActive(identity)) return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', 'An earlier run of this task is still finishing; start it again when that run has ended.'); @@ -203,7 +205,7 @@ export class ItemExecutor { return stopped(options.fromItem ?? plan.items[start]!.id, 'not started', `${checkpoint.item} changed files outside its plan item. Continuing after a scope pause is not supported yet (#88), so this task runs no further items.`); /** Where the next item must start: the context the previous item left, or the current one for the first item. */ - let expected: { snapshotId: string; assignmentId: string; referencedCodeHash: string } | null = null; + let expected: InvocationContext | null = null; for (const item of plan.items.slice(start)) { // Admission reads the context in this same turn, so it cannot notice a change saved during an earlier item. const current = this.#store.currentContext(identity); @@ -212,7 +214,7 @@ export class ItemExecutor { return stopped(item.id, 'not started', `The task's status changed to ${this.#store.getTask(identity).status} during the run; ${item.id} was not started.`); if (this.#store.getPlan(identity).revision !== plan.revision) return stopped(item.id, 'not started', `The plan changed to a new revision during the run; review it before running ${item.id}.`); - if (expected && (current.snapshotId !== expected.snapshotId || current.assignmentId !== expected.assignmentId || current.referencedCodeHash !== expected.referencedCodeHash)) + if (expected && !sameContext(current, expected)) return stopped(item.id, 'not started', `The task's snapshot or assignment changed during the run; review it before running ${item.id}.`); let attempt: AttemptRecord; try { @@ -241,7 +243,8 @@ export class ItemExecutor { if (result.outOfScope.length) return this.#pause(identity, row, result, stopped, done); const snapshotId = result.unchanged ? row.context.snapshotId : this.#store.snapshotWithHead(identity, result.head); if (!snapshotId) throw new Error(`The snapshot of ${item.id}'s commit is missing.`); - expected = { snapshotId, assignmentId: row.context.assignmentId, referencedCodeHash: row.context.referencedCodeHash }; + // The item's own commit (recordHistory) raised the context generation by exactly one; any other change is not ours. + expected = { ...row.context, snapshotId, stateVersion: row.context.stateVersion + (result.unchanged ? 0 : 1) }; } return { kind: 'executed', items: done, unchanged }; } diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index c2da4c65..c4f35d41 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -535,8 +535,13 @@ describe('item execution', () => { const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: async () => { if (store.getAttempts(identity).length === 1) store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs human'); } }); store = h.store; + let version = 0; + const releaseDone = h.workspace.release.bind(h.workspace); + h.workspace.release = async ws => { await releaseDone(ws); version = store.getTask(identity).stateVersion; }; expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); expect(h.findings.get(store.getAttempts(identity)[0]!.id)).toBeUndefined(); + // Settled without a needs human -> needs human write. + expect(store.getTask(identity).stateVersion).toBe(version); }); it('keeps a finding owed when a merge in progress refuses the escalation, and escalates it on the next run', async () => { let store!: Store; @@ -825,4 +830,27 @@ describe('item execution', () => { // Paid from the durable result: no item ran again. expect(store.getAttempts(identity)).toHaveLength(2); }); + it('stops before the next item when only the context generation changes during the run', async () => { + let store!: Store; + const h = setup({ release: async () => { + if (store.getAttempts(identity).length !== 1) return; + const current = store.currentContext(identity); + store.setAssignment(identity, store.getTask(identity).stateVersion, current.assignmentId, current.referencedCodeHash); + } }); + store = h.store; + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P2', state: 'not started', completed: ['P1'], reason: expect.stringMatching(/snapshot or assignment changed/) }); + expect(h.runner.status(identity).unresolved).toBeNull(); + }); + it('pays nothing owed once shutdown began', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, + release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); + store = h.store; + await h.executor.runTask(identity); + store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); + h.runner.rejectAdmission(); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', state: 'not started', reason: 'The review server is shutting down.' }); + expect(store.getTask(identity).status).toBe('queued'); + expect(h.findings.get(store.getAttempts(identity)[0]!.id)).toBeDefined(); + }); }); From fe99203e767038f32920b2e847bdfde1abb66cc1 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 14:27:17 -0700 Subject: [PATCH 22/26] Fix round 19 of the F2b review: one run per task at a time, owed outcomes report not started - ItemExecutor holds a per-task in-flight flag from the start of runTask to its return, so a second run can never pay the first run's pause or finding, even in the microtasks between the coordinator dropping the job and the first run resuming (which the isActive check alone left open). - Paying or keeping an owed finding or pause reports the earlier item with state "not started", not that attempt's old state. - runner-lifecycle.md says when preparation-not-removed happens on each path. - Tests: a second run started at every microtask offset in the first run's release never pays its pause; owed outcomes report not started. Co-Authored-By: Claude Opus 5.5 --- docs/implementation/runner-lifecycle.md | 2 +- runner/execution.ts | 24 ++++++++++++++++++------ test/runner-execution.test.ts | 16 ++++++++++++++-- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/implementation/runner-lifecycle.md b/docs/implementation/runner-lifecycle.md index 1e5b4cca..265e0971 100644 --- a/docs/implementation/runner-lifecycle.md +++ b/docs/implementation/runner-lifecycle.md @@ -292,7 +292,7 @@ This runs before the coordinator opens. ## HTTP and UI contract -**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved, or the host-side preparation files or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review. +**Status reads.** `GET /api/runner` returns only the task and attempt rows plus `stateVersion`, `retryable`, `unresolved` and `stopRequested`. `stopRequested` comes from the in-memory job: null, or `{ attemptId, reason, saved }`, where `saved` is false while the first-reason write has failed. The UI shows "Stopping (not saved yet)" only from this field. It does not change `stateVersion`, so user actions still compare against the durable state version. `unresolved` is computed by the server from the in-memory marker: null, or `{ attemptId, reason: "result-not-saved" | "start-not-saved" | "preparation-not-removed" | "storage-not-removed" }`: the terminal write or the start could not be saved; the host-side preparation files could not be removed (before the terminal write when D never ran, after it once D settled); or the task storage could not be removed after a saved terminal write. The UI shows "Needs restart" with that cause only from this field, because the durable row alone may still look active. It does not rebuild Git history or the full review. **User actions.** Cancel, retry and "run again" requests send `attemptId`, `expectedStateVersion` and an `actionId` idempotency key (see "Feedback-event contract"). A replayed `actionId` returns the saved outcome. The server takes the plan identity from its trusted configuration, never from the request, and every `Store` call is scoped by that identity. A mismatch returns HTTP 409 with the current state. The UI then shows that state and keeps any draft. diff --git a/runner/execution.ts b/runner/execution.ts index 6fcade0b..06bfc0f4 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -1,4 +1,4 @@ -import type { PlanIdentity } from '../core/identity.ts'; +import { identityKey, type PlanIdentity } from '../core/identity.ts'; import type { PlanContext } from '../core/plan.ts'; import type { InvocationContext, InvocationHandle, InvocationInput, TaskClone } from '../agents/contract.ts'; import { prepareExecution } from '../core/execution-prompt.ts'; @@ -179,7 +179,19 @@ export class ItemExecutor { this.#store = store; this.#runner = runner; this.#sources = sources; this.#findings = findings; this.#deadlineMs = options.deadlineMs ?? 10 * 60_000; this.#write = settleWith(options.capability); } + /** Tasks with a runTask in progress here, from its start to its return: a second one waits for none of its steps. */ + #inFlight = new Set(); async runTask(identity: PlanIdentity, options: { fromItem?: string } = {}): Promise { + const key = identityKey(identity); + // Held for the whole run, so a second run can never pay this run's pause or finding, even in the microtasks between + // the coordinator dropping the job and this run resuming. + if (this.#inFlight.has(key)) return { kind: 'stopped', item: options.fromItem ?? this.#store.getPlan(identity).items[0]!.id, state: 'not started', + reason: 'An earlier run of this task is still finishing; start it again when that run has ended.', completed: [] }; + this.#inFlight.add(key); + try { return await this.#runTask(identity, options); } + finally { this.#inFlight.delete(key); } + } + async #runTask(identity: PlanIdentity, options: { fromItem?: string }): Promise { const plan = this.#store.getPlan(identity); const start = options.fromItem ? plan.items.findIndex(item => item.id === options.fromItem) : 0; if (start < 0) throw new Error('Unknown plan item.'); @@ -260,11 +272,11 @@ export class ItemExecutor { // The terminal write failed: the attempt still counts as active, so the move waits for restart; keep the finding owed. if (row.state === 'pending' || row.state === 'running') { const unresolved = this.#runner.status(identity).unresolved; - return stopped(item, row.state, `${violation} ${unresolved ? NEEDS_RESTART[unresolved.reason] : 'Needs restart: the attempt\'s outcome could not be saved.'}`); + return stopped(item, owed ? 'not started' : row.state, `${violation} ${unresolved ? NEEDS_RESTART[unresolved.reason] : 'Needs restart: the attempt\'s outcome could not be saved.'}`); } if (CLOSED_STATUSES.includes(task.status)) { this.#findings.settle(row.id); - return stopped(item, row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); + return stopped(item, owed ? 'not started' : row.state, `${violation} The task is ${task.status}, so it was not moved to needs human.`); } // Already where the finding sends it: nothing is owed. if (task.status === 'needs human') { @@ -272,13 +284,13 @@ export class ItemExecutor { return { kind: 'needs human', item, reason: violation, completed: [...done] }; } if (HUMAN_GATES.includes(task.status)) - return stopped(item, row.state, `${violation} The task is ${task.status}; it moves to needs human when it next runs.`); + return stopped(item, owed ? 'not started' : row.state, `${violation} The task is ${task.status}; it moves to needs human when it next runs.`); // Settling this run's own attempt writes through the shutdown capability; paying an owed finding at the start of a // new run is that run's decision, so it does not, and the closed write gate refuses it like any other. try { (owed ? direct : this.#write)(() => this.#store.transitionTask(identity, task.stateVersion, 'needs human')); } catch (error) { if (!(error instanceof GuardRefusal) && !(owed && error instanceof ShuttingDownError)) throw error; - return stopped(item, row.state, `${violation} The task could not be moved to needs human yet: ${(error as Error).message}`); + return stopped(item, owed ? 'not started' : row.state, `${violation} The task could not be moved to needs human yet: ${(error as Error).message}`); } this.#findings.settle(row.id); return { kind: 'needs human', item, reason: violation, completed: [...done] }; @@ -312,7 +324,7 @@ export class ItemExecutor { }, { owed })).id; } catch (error) { if (!(error instanceof GuardRefusal) && !(owed && error instanceof ShuttingDownError)) throw error; - return stopped(item, row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${(error as Error).message}`); + return stopped(item, owed ? 'not started' : row.state, `${item} changed files outside its plan item, but the task could not pause for amendment: ${(error as Error).message}`); } return { kind: 'needs amendment', item, outOfScope: result.outOfScope, checkpointId, completed: [...done] }; } diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index c4f35d41..4c7c3042 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Store } from '../runner/store.ts'; import { RunnerCoordinator } from '../runner/coordinator.ts'; -import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; +import { ItemExecutor, SAFETY_VIOLATION, SafetyFindings, executionDeps, type ExecutionOutcome, type ExecutionSources, type TaskWorkspace, type WorkspaceRef } from '../runner/execution.ts'; import { MAX_REASON, ShuttingDownError, type ShutdownCapability } from '../runner/lifecycle.ts'; import type { ChangeManifest, ManifestChange } from '../core/run-audit.ts'; import type { InvocationResult } from '../agents/contract.ts'; @@ -754,7 +754,7 @@ describe('item execution', () => { const attemptId = store.getAttempts(identity)[0]!.id; store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); store.closeWrites(); - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/could not be moved to needs human yet: The review server is shutting down/) }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'not started', reason: expect.stringMatching(/could not be moved to needs human yet: The review server is shutting down/) }); expect(store.getTask(identity).status).toBe('queued'); expect(h.findings.get(attemptId)).toBeDefined(); }); @@ -853,4 +853,16 @@ describe('item execution', () => { expect(store.getTask(identity).status).toBe('queued'); expect(h.findings.get(store.getAttempts(identity)[0]!.id)).toBeDefined(); }); + it('never lets a second run pay the first run\'s pause, whatever microtask it starts in', async () => { + for (let steps = 0; steps <= 24; steps++) { + let executor!: ItemExecutor, second: Promise | undefined; + const h = setup({ manifests: { P1: manifest([change('a.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) }, release: async () => { + void (async () => { for (let i = 0; i < steps; i++) await null; second = executor.runTask(identity); })(); + } }); + executor = h.executor; + expect(await h.executor.runTask(identity), `after ${steps} steps`).toMatchObject({ kind: 'needs amendment', item: 'P1' }); + for (let i = 0; i < 50 && !second; i++) await null; + expect((await second)?.kind, `after ${steps} steps`).toBe('stopped'); + } + }); }); From 955149db315d7680dd315328d7a14afc7d62b051 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 15:11:18 -0700 Subject: [PATCH 23/26] Cover round 20 of the F2b review: every owed not-started path, the outside-job check, line separators Round 20 found no correctness bug. These tests close its gaps: an owed finding at a human gate, on a closed task and with a failed terminal write, and an owed pause refused at a gate, each reported as not started; runTask refusing while a job started outside the executor is still releasing its storage; and U+2028 in a plan title. Co-Authored-By: Claude Opus 5.5 --- test/runner-execution.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index 4c7c3042..eed8d78b 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -341,6 +341,8 @@ describe('item execution', () => { store = unsafe.store; expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/needs approval; it moves to needs human when it next runs/) }); expect(store.getTask(identity).status).toBe('needs approval'); + // Still at the gate on the next run: the finding stays owed, reported as not started. + expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'not started', reason: expect.stringMatching(/needs approval; it moves to needs human/) }); // A person releases the gate; the owed finding goes to needs human before any item runs again. store.transitionTask(identity, store.getTask(identity).stateVersion, 'queued'); expect(await unsafe.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); @@ -575,7 +577,7 @@ describe('item execution', () => { const attemptId = store.getAttempts(identity)[0]!.id; expect(h.findings.get(attemptId)).toBeDefined(); store.cancelTask(identity, store.getTask(identity).stateVersion, randomUUID()); - expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', reason: expect.stringMatching(/is cancelled/) }); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'not started', reason: expect.stringMatching(/is cancelled/) }); expect(h.findings.get(attemptId)).toBeUndefined(); }); it('records completed and the ledger entry in one transaction: a failed history write leaves neither', async () => { @@ -614,6 +616,8 @@ describe('item execution', () => { release: async () => { store.transitionTask(identity, store.getTask(identity).stateVersion, 'needs approval'); } }); store = h.store; expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1' }); + // Still at the gate: the owed pause is refused and reported as not started. + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'not started', reason: expect.stringMatching(/could not pause for amendment/) }); store.transitionTask(identity, store.getTask(identity).stateVersion, 'in review'); expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs amendment', item: 'P1' }); expect(store.getTask(identity).status).toBe('needs amendment'); @@ -651,7 +655,7 @@ describe('item execution', () => { .toThrow(/Unknown snapshot/); }); it('writes a plan title with line breaks as one line in the runner commit message', async () => { - const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\nPlan-Revision: r99' }, plan.items[1]!] }; + const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\u2028Plan-Revision: r99' }, plan.items[1]!] }; const h = setup({ plan: forged }); await h.executor.runTask(identity); expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99'); @@ -774,6 +778,8 @@ describe('item execution', () => { expect(outcome.kind).toBe('stopped'); expect(outcome.reason).toMatch(/Needs restart: the last result could not be saved\./); expect(h.findings.get(h.store.getAttempts(identity)[0]!.id)).toBeDefined(); + // The next run finds it owed, reports it for the earlier item, and starts nothing. + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'stopped', item: 'P1', state: 'not started', reason: expect.stringMatching(/Needs restart/) }); }); it('ends as the stop, not a refused commit, when a stop lands and the commit then rejects', async () => { let runner!: RunnerCoordinator, store!: Store; @@ -865,4 +871,14 @@ describe('item execution', () => { expect((await second)?.kind, `after ${steps} steps`).toBe('stopped'); } }); + it('does not start while a job started outside the executor is still releasing its storage', async () => { + let executor!: ItemExecutor, during: Promise | undefined; + const h = setup({ release: async () => { during = executor.runTask(identity); await during; } }); + executor = h.executor; + h.runner.start(identity, { expectedStateVersion: h.store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', + expectedContext: h.store.currentContext(identity), deadline: Date.now() + 60_000 }); + await h.runner.settled(identity); + expect(await during).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/still finishing/) }); + expect(h.store.getAttempts(identity)).toHaveLength(1); + }); }); From 86c7a5f96155c48068ae112da92c291cf6dde7b2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 18:26:41 -0700 Subject: [PATCH 24/26] Cover round 21 of the F2b review: a pause at a later item, an older owed finding Round 21 found no correctness bug. Tests now cover a scope pause at P2 (the checkpoint names the whole executed prefix) and an older owed finding escalated although a later attempt completed cleanly. The ItemExecutor docs state that its one-run-per-task guard needs one executor per Store. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 5 ++++- test/runner-execution.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/runner/execution.ts b/runner/execution.ts index 06bfc0f4..50746203 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -179,7 +179,10 @@ export class ItemExecutor { this.#store = store; this.#runner = runner; this.#sources = sources; this.#findings = findings; this.#deadlineMs = options.deadlineMs ?? 10 * 60_000; this.#write = settleWith(options.capability); } - /** Tasks with a runTask in progress here, from its start to its return: a second one waits for none of its steps. */ + /** + * Tasks with a runTask in progress here, from its start to its return: a second one waits for none of its steps. + * The guard is per instance, so the server must keep one ItemExecutor per Store (as it keeps one coordinator). + */ #inFlight = new Set(); async runTask(identity: PlanIdentity, options: { fromItem?: string } = {}): Promise { const key = identityKey(identity); diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index eed8d78b..f76106f6 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -881,4 +881,22 @@ describe('item execution', () => { expect(await during).toMatchObject({ kind: 'stopped', state: 'not started', reason: expect.stringMatching(/still finishing/) }); expect(h.store.getAttempts(identity)).toHaveLength(1); }); + it('pauses at a later item with the whole executed prefix', async () => { + const h = setup({ manifests: { P2: manifest([change('b.ts'), change('extra.ts', { kind: 'add', oldType: undefined })]) } }); + const outcome = await h.executor.runTask(identity) as { kind: string; item: string; checkpointId: string; completed: string[] }; + expect(outcome).toMatchObject({ kind: 'needs amendment', item: 'P2', completed: ['P1', 'P2'] }); + expect(h.store.getCheckpoint(identity, outcome.checkpointId)).toMatchObject({ item: 'P2', completedItems: ['P1', 'P2'], outOfScopePaths: ['extra.ts'] }); + }); + it('escalates an older owed finding even when a later attempt completed cleanly', async () => { + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) } }); + const store = h.store; + // Both attempts start outside the executor, so nothing acts on P1's finding yet. + h.runner.start(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P1', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + await h.runner.settled(identity); + h.runner.start(identity, { expectedStateVersion: store.getTask(identity).stateVersion, kind: 'execute', item: 'P2', expectedContext: store.currentContext(identity), deadline: Date.now() + 60_000 }); + await h.runner.settled(identity); + expect(store.getAttempts(identity).map(row => row.state)).toEqual(['failed', 'completed']); + expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); + expect(store.getAttempts(identity)).toHaveLength(2); + }); }); From 7bbfa731ce2d1655b43c28c58ac6b443b056357c Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 18:44:43 -0700 Subject: [PATCH 25/26] Fix round 22 of the F2b review: no control characters in the commit title, test a link to .. - The runner commit's title drops every control character, not only line breaks, so an agent-written plan title cannot put terminal escapes into git log. (A NUL is already refused earlier, by the prompt builder.) - A test covers a declared link retargeted to "..", the directory that holds the repository. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 5 +++-- test/run-audit.test.ts | 4 ++++ test/runner-execution.test.ts | 5 +++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/runner/execution.ts b/runner/execution.ts index 50746203..20cf7344 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -115,8 +115,9 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag let head: string; try { head = await workspace.commit(data.workspace, { baseHead: data.baseHead, paths, digest: manifest.digest, - // The title is plan text: on one line, it cannot open a trailer block that forges Plan-Item or Plan-Revision. - message: `${item.id}: ${item.title.replace(/[\r\n\u2028\u2029]+/g, ' ').trim()}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, + // The title is plan text: on one line with no control characters, it cannot open a trailer block that forges + // Plan-Item or Plan-Revision, stop Git with a NUL, or put terminal escapes into git log. + message: `${item.id}: ${item.title.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/ {2,}/g, ' ').trim()}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); } catch (error) { // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). A stop records its first reason before it diff --git a/test/run-audit.test.ts b/test/run-audit.test.ts index a6a72af0..813b70c0 100644 --- a/test/run-audit.test.ts +++ b/test/run-audit.test.ts @@ -117,6 +117,10 @@ describe('post-run audit', () => { it('trusts D\'s underGit flag on its own', () => { expect(auditRun(item, manifest([file('src/retry.ts', { underGit: true })]), exact)).toEqual({ kind: 'violation', violations: ['The agent changed "src/retry.ts" under .git.'] }); }); + it('refuses a declared link retargeted to the directory that holds the repository', () => { + expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: '..', linkTargetTraversesLink: false })]), exact)) + .toEqual({ kind: 'violation', violations: ['Unsafe symlink target at "link": target leaves the repository.'] }); + }); it('refuses a declared link retargeted to the repository root', () => { for (const target of ['.', './', 'a/..']) expect(auditRun(item, manifest([file('link', { oldType: 'symlink', newType: 'symlink', newLinkTarget: target, linkTargetTraversesLink: false })]), exact), target) diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index f76106f6..ad11e69c 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -655,10 +655,11 @@ describe('item execution', () => { .toThrow(/Unknown snapshot/); }); it('writes a plan title with line breaks as one line in the runner commit message', async () => { - const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\u2028Plan-Revision: r99' }, plan.items[1]!] }; + const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\u2028Plan-Revision: r99 \u001b[31mred\u000b\u007f' }, plan.items[1]!] }; const h = setup({ plan: forged }); await h.executor.runTask(identity); - expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99'); + // A NUL is refused earlier, by the prompt builder (plan data must be valid text); other controls reach here. + expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99 [31mred'); expect(h.commits[0]!.trailers).toEqual({ 'Plan-Item': 'P1', 'Plan-Revision': 'r1' }); }); it('stops before the next item when only the assignment changes during the run', async () => { From 5a7c1c8f377ee7630448be92e47cbd22ee8248f2 Mon Sep 17 00:00:00 2001 From: mchwang Date: Wed, 30 Sep 2026 18:58:49 -0700 Subject: [PATCH 26/26] Fix round 23 of the F2b review: no bidi or format characters in the commit title, two more tests Round 23 found no correctness bug. - The runner commit's title also drops bidi and invisible format characters (U+200B-U+200F, U+202A-U+202E, U+2060-U+206F, U+FEFF), so an agent-written plan title cannot reorder how git log shows it. - Tests cover the C1 control range in that filter, and this run's own escalation rethrowing a closed write gate's error when it has no capability. Co-Authored-By: Claude Opus 5.5 --- runner/execution.ts | 6 +++--- test/runner-execution.test.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/runner/execution.ts b/runner/execution.ts index 20cf7344..41a9bfcc 100644 --- a/runner/execution.ts +++ b/runner/execution.ts @@ -115,9 +115,9 @@ export function executionDeps(store: Store, workspace: TaskWorkspace, launch: Ag let head: string; try { head = await workspace.commit(data.workspace, { baseHead: data.baseHead, paths, digest: manifest.digest, - // The title is plan text: on one line with no control characters, it cannot open a trailer block that forges - // Plan-Item or Plan-Revision, stop Git with a NUL, or put terminal escapes into git log. - message: `${item.id}: ${item.title.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/ {2,}/g, ' ').trim()}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, + // The title is plan text: on one line with no control or bidi/format characters, it cannot open a trailer block + // that forges Plan-Item or Plan-Revision, put terminal escapes into git log, or reorder how git log shows it. + message: `${item.id}: ${item.title.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]+/g, ' ').replace(/ {2,}/g, ' ').trim()}`, trailers: { 'Plan-Item': item.id, 'Plan-Revision': `r${plan.revision}` }, }, signal); } catch (error) { // D's refusal text can name agent-chosen paths: quote it (AGENTS.md). A stop records its first reason before it diff --git a/test/runner-execution.test.ts b/test/runner-execution.test.ts index ad11e69c..c223e168 100644 --- a/test/runner-execution.test.ts +++ b/test/runner-execution.test.ts @@ -655,11 +655,11 @@ describe('item execution', () => { .toThrow(/Unknown snapshot/); }); it('writes a plan title with line breaks as one line in the runner commit message', async () => { - const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\u2028Plan-Revision: r99 \u001b[31mred\u000b\u007f' }, plan.items[1]!] }; + const forged: Plan = { ...plan, items: [{ ...plan.items[0]!, title: 'First\n\nPlan-Item: P9\u2028Plan-Revision: r99 \u001b[31mred\u000b\u007f\u009b2J \u202eevil\u2066x\u200b' }, plan.items[1]!] }; const h = setup({ plan: forged }); await h.executor.runTask(identity); // A NUL is refused earlier, by the prompt builder (plan data must be valid text); other controls reach here. - expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99 [31mred'); + expect(h.commits[0]!.message).toBe('P1: First Plan-Item: P9 Plan-Revision: r99 [31mred 2J evil x'); expect(h.commits[0]!.trailers).toEqual({ 'Plan-Item': 'P1', 'Plan-Revision': 'r1' }); }); it('stops before the next item when only the assignment changes during the run', async () => { @@ -900,4 +900,11 @@ describe('item execution', () => { expect(await h.executor.runTask(identity)).toMatchObject({ kind: 'needs human', item: 'P1' }); expect(store.getAttempts(identity)).toHaveLength(2); }); + it('throws, rather than reporting stopped, when this run\'s own escalation meets a closed write gate without a capability', async () => { + let store!: Store; + const h = setup({ manifests: { P1: manifest([change('a.ts')], { metadataChanged: true }) }, release: async () => { store.closeWrites(); } }); + store = h.store; + await expect(h.executor.runTask(identity)).rejects.toBeInstanceOf(ShuttingDownError); + expect(h.findings.get(store.getAttempts(identity)[0]!.id)).toBeDefined(); + }); });