Skip to content

Commit 70a1d40

Browse files
mchwangclaude
andcommitted
F1a: persist the runner lifecycle in the Store
Schema v6 with a v5 backfill: tasks, attempts, user_actions and feedback_events. Guarded attempt transitions, where the Store chooses the terminal state from the first reason, D's result and context currency. State version and context generation counters, cancel task, merge closure, replayable user actions (including refusals) and feedback events. Implements the Store slice of docs/implementation/runner-lifecycle.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e44d04a commit 70a1d40

3 files changed

Lines changed: 738 additions & 9 deletions

File tree

‎runner/lifecycle.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createHash } from 'node:crypto';
2+
import type { InvocationContext, Phase, StopReason } from '../agents/contract.ts';
3+
4+
/** F1 runner lifecycle vocabulary. See docs/implementation/runner-lifecycle.md. */
5+
export type AttemptState = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'stale';
6+
export type FirstReason = 'cancelled' | 'shutdown' | 'stale' | 'time-limit';
7+
export type AttemptKind = 'planning' | 'question' | 'review' | 'check' | 'execute' | 'fix' | 'rebase-fix';
8+
export type TaskStatus = 'queued' | 'running' | 'needs human' | 'needs amendment' | 'needs approval'
9+
| 'possibly already fixed' | 'in review' | 'approved but merge blocked' | 'merged' | 'cancelled';
10+
11+
export const TASK_STATUSES: readonly TaskStatus[] = ['queued', 'running', 'needs human', 'needs amendment', 'needs approval',
12+
'possibly already fixed', 'in review', 'approved but merge blocked', 'merged', 'cancelled'];
13+
export const CLOSED_STATUSES: readonly TaskStatus[] = ['merged', 'cancelled'];
14+
export const TERMINAL_STATES: readonly AttemptState[] = ['completed', 'failed', 'cancelled', 'stale'];
15+
export const FIRST_REASONS: readonly FirstReason[] = ['cancelled', 'shutdown', 'stale', 'time-limit'];
16+
/** Each F attempt kind runs under exactly one existing D phase. */
17+
export const ATTEMPT_PHASES = {
18+
planning: 'planning', question: 'questions', review: 'review', check: 'review',
19+
execute: 'execute', fix: 'fix', 'rebase-fix': 'fix',
20+
} as const satisfies Record<AttemptKind, Phase>;
21+
export const WRITABLE_KINDS: readonly AttemptKind[] = ['execute', 'fix', 'rebase-fix'];
22+
export const MAX_REASON = 4000;
23+
export const MAX_RESULT_BYTES = 1024 * 1024;
24+
export const DEFAULT_TASK_BUDGET_MS = 2 * 60 * 60 * 1000;
25+
26+
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
27+
/** Attempt, action and allocation IDs are lowercase UUID v4s; anything else is refused before use. */
28+
export function isUuidV4(value: unknown): value is string { return typeof value === 'string' && UUID_V4.test(value); }
29+
export function assertUuidV4(value: unknown, name: string): asserts value is string {
30+
if (!isUuidV4(value)) throw new GuardRefusal(`${name} must be a lowercase UUID v4.`);
31+
}
32+
33+
/** A guard refused the action. Refusals are definite outcomes and are recorded for replay. */
34+
export class GuardRefusal extends Error {}
35+
/** Reusing an action ID for a different request. */
36+
export class ActionIdReused extends GuardRefusal {}
37+
38+
export function bounded(reason: string): string {
39+
const text = reason.trim() || 'No reason given.';
40+
return text.length > MAX_REASON ? `${text.slice(0, MAX_REASON - 1)}…` : text;
41+
}
42+
43+
/** Stable fingerprint of a user action request; key order does not matter. */
44+
export function requestHash(kind: string, request: unknown): string {
45+
const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical)
46+
: value && typeof value === 'object'
47+
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical((value as Record<string, unknown>)[key])]))
48+
: value;
49+
return createHash('sha256').update(JSON.stringify([kind, canonical(request)])).digest('hex');
50+
}
51+
52+
export interface Settlement {
53+
/** F's first reason: the durable one, or the in-memory one if its write failed. */
54+
firstReason: FirstReason | null;
55+
contextCurrent: boolean;
56+
stopReason?: StopReason;
57+
exitCode: number | null;
58+
/** Output passed validation (schema, size and, for writable attempts, the file-scope audit). */
59+
valid: boolean;
60+
/** Bounded actionable detail from the provider, used for generic failures. */
61+
detail?: string;
62+
}
63+
export interface Classification {
64+
state: Exclude<AttemptState, 'pending' | 'running'>;
65+
reason: string | null;
66+
/** The task budget ran out; the task moves to needs human unless it is closed or a cancel is pending. */
67+
timeLimit: boolean;
68+
}
69+
70+
/** Terminal-state precedence at settlement. Order matters; see "Rules for the running state", rule 2. */
71+
export function classifySettlement(s: Settlement): Classification {
72+
const dFailure = s.stopReason === 'timeout' || s.stopReason === 'output-limit' || s.stopReason === 'capture-failure';
73+
const dReason = () => s.stopReason === 'timeout' ? 'Timed out.' : bounded(s.detail ?? `Agent stopped: ${s.stopReason}.`);
74+
switch (s.firstReason) {
75+
case 'cancelled': return { state: 'cancelled', reason: 'Cancelled.', timeLimit: false };
76+
// D keeps the first reason it received: a non-shutdown stop reason proves D stopped before shutdown reached it.
77+
case 'shutdown': return dFailure ? { state: 'failed', reason: dReason(), timeLimit: false }
78+
: { state: 'cancelled', reason: 'Stopped by shutdown', timeLimit: false };
79+
case 'stale': return { state: 'stale', reason: bounded(s.detail ?? 'The plan, snapshot, assignment or referenced code changed.'), timeLimit: false };
80+
case 'time-limit': return { state: 'cancelled', reason: 'Task time limit reached', timeLimit: true };
81+
}
82+
if (!s.contextCurrent) return { state: 'stale', reason: 'The plan, snapshot, assignment or referenced code changed.', timeLimit: false };
83+
if (dFailure) return { state: 'failed', reason: dReason(), timeLimit: false };
84+
if (s.exitCode === 0 && s.valid) return { state: 'completed', reason: null, timeLimit: false };
85+
return { state: 'failed', reason: bounded(s.detail ?? `Agent exited with code ${s.exitCode ?? 'none'}.`), timeLimit: false };
86+
}
87+
88+
export function sameContext(a: InvocationContext, b: InvocationContext): boolean {
89+
return a.snapshotId === b.snapshotId && a.planId === b.planId && a.planRevision === b.planRevision
90+
&& a.assignmentId === b.assignmentId && a.referencedCodeHash === b.referencedCodeHash && a.stateVersion === b.stateVersion;
91+
}

0 commit comments

Comments
 (0)