|
| 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