/** * A3 — automatic recovery wiring (verdict #14, 2026-09-18). * * Two thin layers sit above the A13 RecoveryEpisode: * * RunContext — the explicit-run authorization envelope. A loop started by the * user authorizes ROUNDS, not recovery. Any episode that reaches * give-up ends the run as STOPPED_GIVE_UP; nothing may start a * new round automatically. Only an explicit start/resume/handoff * creates a new run. * classifyFailure — the single entry point for "may this failure enter the * recovery engine at all". Most failures are TERMINAL or * NOT_APPLICABLE; only the named runtime failures are * RECOVERABLE. Recovery may add effort, never permission. * * Time accounting is frozen: maxWallClockMs counts EVERYTHING (relay, waits, * backoff, reload, reopen, relaunch, attach, handshake recovery); maxRounds * only increments when a round dispatch actually starts, and recovery actions * never consume a round. */ import { RECOVERY_ACTIONS, type RecoveryActionId, type RecoveryEpisode } from "../core/recovery.ts"; import { redactForDiagnostics } from "./diagnostics.ts"; /* ------------------------------------------------------------------ */ /* RunContext */ /* ------------------------------------------------------------------ */ export type RunState = | "RUNNING" | "COMPLETED" | "STOPPED_BY_USER" | "STOPPED_LIMIT" | "STOPPED_GIVE_UP" | "STOPPED_FATAL"; export interface RunContextOptions { startedBy: "start" | "resume" | "handoff" | "tool"; deadlineMs?: number; maxRounds?: number; now?: () => number; } const randomHex = () => { const bytes = new Uint8Array(6); for (let i = 0; i < 6; i += 1) bytes[i] = Math.floor(Math.random() * 256); return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); }; export class RunContext { readonly runId: string; readonly startedBy: RunContextOptions["startedBy"]; readonly startedAt: number; readonly deadline: number; readonly maxRounds: number; state: RunState = "RUNNING"; terminalReason: string | null = null; /** Only increments when a round dispatch actually starts (verdict #14 Q4). */ roundsStarted = 0; /** The protocol round identity; recovery must keep it stable. */ currentRound: number | null = null; constructor(options: RunContextOptions, now = Date.now()) { this.runId = `run-${randomHex()}`; this.startedBy = options.startedBy; this.startedAt = now; this.deadline = now + (options.deadlineMs ?? 90 * 60_000); this.maxRounds = options.maxRounds ?? 8; } /** Wall clock since the run started — recovery/backoff time is NOT excluded. */ elapsedMs(now = Date.now()): number { return now - this.startedAt; } expired(now = Date.now()): boolean { return now >= this.deadline; } /** * Called exactly when a protocol round begins dispatching. Recovery actions * inside the same round keep the SAME round identity, so re-beginning the * same round number does not consume another round (verdict #14 Q4). */ beginRound(roundNumber: number): void { if (this.state !== "RUNNING") return; if (this.currentRound === roundNumber) return; // same round, recovery kept identity this.roundsStarted += 1; this.currentRound = roundNumber; } transition(next: Exclude, reason: string): void { if (this.state !== "RUNNING") return; // terminal is absorbing this.state = next; this.terminalReason = reason; } } /* ------------------------------------------------------------------ */ /* Failure classification */ /* ------------------------------------------------------------------ */ export type RecoveryDisposition = | { kind: "RECOVERABLE"; stallKind: string; detail: string } | { kind: "TERMINAL"; reason: string } | { kind: "NOT_APPLICABLE"; reason: string }; /** Patterns that name a runtime/temporary condition (recoverable). */ const RECOVERABLE_PATTERNS: Array<{ re: RegExp; stallKind: string }> = [ { re: /Cannot reach Chrome DevTools|调试端口.*没有|endpoint.*not.*running|无法连接|ECONNREFUSED/i, stallKind: "browser-gone" }, { re: /没有找到会话|tab.*not found|No chatgpt\.com tab/i, stallKind: "page-unresponsive" }, { re: /target.*stale|Session closed|WebSocket.*closed|attach.*fail/i, stallKind: "page-unresponsive" }, { re: /超时|timeout|timed out/i, stallKind: "page-unresponsive" }, { re: /没有送达|data-message-id|identityUnavailable|delivery/i, stallKind: "not-delivered" }, { re: /正在思考|placeholder/i, stallKind: "thinking-placeholder" }, { re: /stream|frozen|停滞/i, stallKind: "stream-frozen" }, { re: /验证|限额|logout|登录|blocked|Cloudflare|challenge/i, stallKind: "blocked" }, ]; /** Patterns that name a permanent condition (terminal — recovery cannot fix it). */ const TERMINAL_PATTERNS: Array<{ re: RegExp; reason: string }> = [ { re: /Node|需要 Node|版本过低|fetch.*require/i, reason: "运行时/环境不满足" }, { re: /配置非法|config.*invalid|profileMode/i, reason: "配置非法" }, { re: /executable|找不到.*exe|ENOENT/i, reason: "可执行文件不存在" }, { re: /capability|授权|consent|拒绝授权|没有.*权限/i, reason: "capability/consent 拒绝" }, { re: /conversation.*id|会话 id.*缺失|conversationUrl 为空/i, reason: "缺会话 id(绝不经恢复新建)" }, { re: /协议|HV\/CV|CAP|frame.*malformed|不兼容|契约|handshake.*fail|ACK|HELLO/i, reason: "协议不兼容/契约违反(不可自动恢复)" }, { re: /identity mismatch|会话不匹配|not the declared/i, reason: "目标身份不一致" }, ]; /** * The single classification entry (verdict #14): recovery may only ever see the * named runtime failures; everything else is TERMINAL or NOT_APPLICABLE. */ export function classifyFailure( error: unknown, context?: { operation?: string }, ): RecoveryDisposition { const message = String((error && (error as Error).message) || error || ""); for (const t of TERMINAL_PATTERNS) { if (t.re.test(message)) return { kind: "TERMINAL", reason: `${t.reason}(${message.slice(0, 60)})` }; } for (const r of RECOVERABLE_PATTERNS) { if (r.re.test(message)) return { kind: "RECOVERABLE", stallKind: r.stallKind, detail: message.slice(0, 80) }; } return { kind: "NOT_APPLICABLE", reason: `不可识别为可恢复故障:${message.slice(0, 80)}` }; } /* ------------------------------------------------------------------ */ /* Give-up event contract (verdict #14 Q3) */ /* ------------------------------------------------------------------ */ export interface RecoveryActionRecord { action: RecoveryActionId; attempt: number; outcome: "attempted" | "denied" | "executed"; } export interface GiveUpEvent { eventVersion: 1; event: "recovery.give_up"; episodeId: string; runId: string | null; roundNumber: number | null; originalOperation: string; originalOperationId: string | null; conversationId: string | null; // 已知才写 stallKind: string; failureClass: string; elapsedMs: number; actionsAttempted: RecoveryActionRecord[]; resendBudgetConsumed: number; relaunchBudgetConsumed: number; lastAction: string | null; reason: string; recommendedManualAction: string; terminalAt: number; recoverability: "requires-human"; } export interface GiveUpEventOptions { episode: RecoveryEpisode; run: RunContext | null; roundNumber: number | null; originalOperation: string; conversationId: string | null; stallKind: string; failureClass: string; reason: string; recommendedManualAction?: string; now?: number; } export function buildGiveUpEvent(options: GiveUpEventOptions): GiveUpEvent { const ep = options.episode; const summary = ep.summary(); const actionsAttempted: RecoveryActionRecord[] = summary.trail.map((entry, i) => { const [action] = entry.split(/\s/, 1); return { action: (RECOVERY_ACTIONS.find((a) => a.id === action)?.id ?? action) as RecoveryActionId, attempt: i + 1, outcome: "attempted", }; }); return { eventVersion: 1, event: "recovery.give_up", episodeId: ep.id, runId: options.run?.runId ?? null, roundNumber: options.roundNumber, originalOperation: options.originalOperation, originalOperationId: ep.originalOperation, conversationId: options.conversationId, stallKind: options.stallKind, failureClass: options.failureClass, elapsedMs: summary.terminal ? Date.now() - ep.startedAt : 0, actionsAttempted, resendBudgetConsumed: summary.budgetUsed.resend ?? 0, relaunchBudgetConsumed: summary.budgetUsed["relaunch-browser"] ?? 0, lastAction: summary.trail[summary.trail.length - 1] ?? null, reason: redactForDiagnostics(options.reason), recommendedManualAction: options.recommendedManualAction ?? "检查页面与网络后显式重新发起(start / resume / handoff)", terminalAt: options.now ?? Date.now(), recoverability: "requires-human", }; } /* ------------------------------------------------------------------ */ /* Canary read-only seam (verdict #14 Q3/DoD 2) */ /* ------------------------------------------------------------------ */ export type RecoveryListener = (event: GiveUpEvent) => void; /** * The Canary observation seam. Listeners CONSUME events only; there is no API * here to begin an episode, start a run, or resume anything. A listener that * saw a give-up must never restart the same episode — the absorbing terminal * is enforced by RecoveryEpisode/RunContext, and this seam adds no bypass. */ export class RecoveryEventBus { private readonly listeners: RecoveryListener[] = []; readonly history: GiveUpEvent[] = []; subscribe(listener: RecoveryListener): () => void { this.listeners.push(listener); return () => { const i = this.listeners.indexOf(listener); if (i >= 0) this.listeners.splice(i, 1); }; } emit(event: GiveUpEvent): void { this.history.push(event); for (const listener of this.listeners.slice()) { try { listener(event); } catch { // a listener must never break the recovery path } } } }