/** * A13 — recovery escalation 统一约束(裁决 #13,2026-09-18)。 * * 核心模型冻结为两句话: * Recovery 可以增加"努力程度",不能增加"权限"。 * kind 可以重置阶梯,operation 不能重置风险预算。 * * 这里只有一个权威动作表(RECOVERY_ACTIONS)。cycle.ts 的 STALL_ACTIONS 由此派生, * 不允许存在第二份手工同步的列表——注册表外动作 = 结构测试失败(DoD 1)。 * * 副作用所有权(DoD 2):每个动作要么 effect:"none",要么引用已有 SideEffectId; * recovery registry 可以收窄 SIDE_EFFECTS,但绝不能重新定义或放宽它。原 capability * 只授权原 operation;恢复若需要另一个受保护 side effect,必须满足那个 side effect * 自己的授权条件(DoD 3 的推论:观测失败本身不产生任何新授权)。 */ import type { SideEffectId } from "./sideEffects.ts"; import type { StallKind } from "./cycle.ts"; export type RecoveryEffect = "none" | SideEffectId; export interface RecoveryActionDef { /** Registry id;也是 cycle/index 里使用的动作名。 */ id: string; /** 该动作触碰的受保护副作用,或 none。 */ effect: RecoveryEffect; /** 该动作适用于哪些 stall kind。 */ allowedKinds: readonly StallKind[]; /** * 需要交付状态已 reconcile(reload:无在途提交或已 reconcile;resend:已证明未送达)。 * 提交结果未知时执行这类动作 = destructive observation(毁掉判断"到底发没发出去"的证据)。 */ requiresReconcile?: boolean; /** reopen 类动作必须有已知 conversation id;永不退化为 create。 */ needsConversationId?: boolean; /** 整个 episode 的全局预算(绝不因 kind 改变而重置)。 */ episodeBudget?: number; /** 终态:进入后同 episode 任何动作被拒绝。 */ terminal?: boolean; /** 升级序(同一 kind 内按 rank 前进)。 */ rank: number; note: string; } /** * 唯一动作全集。派生 StallAction 的唯一来源。 * * effect 引用 sideEffects.ts 的 SideEffectId(browser.launch / message.send 等), * 而不是在这里重新定义"浏览器启动风险"——那是 SIDE_EFFECTS 的职责。 */ export const RECOVERY_ACTIONS: readonly RecoveryActionDef[] = [ { id: "nudge", effect: "none", allowedKinds: ["page-unresponsive", "thinking-placeholder", "stream-frozen", "browser-gone", "blocked", "not-delivered"], rank: 0, note: "提示用户人工介入(不改变任何状态)。", }, { id: "retry-read", effect: "none", allowedKinds: ["page-unresponsive", "thinking-placeholder", "stream-frozen"], rank: 1, note: "重新读一次页面状态;偶发超时的第一处置。", }, { id: "activate-tab", effect: "none", allowedKinds: ["page-unresponsive", "thinking-placeholder", "stream-frozen", "not-delivered"], rank: 2, note: "把标签页调到前台再读;后台渲染会被降级。", }, { id: "press-stop", effect: "none", allowedKinds: ["thinking-placeholder", "stream-frozen"], rank: 3, note: "点停止按钮。它改变生成状态,但不能被当作'证明消息没发送'。", }, { id: "reload-tab", effect: "none", allowedKinds: ["page-unresponsive", "thinking-placeholder", "stream-frozen", "not-delivered"], requiresReconcile: true, rank: 4, note: "重载标签页。提交结果未知时禁止(reload safety verdict)。", }, { id: "reopen-conversation", effect: "none", allowedKinds: ["page-unresponsive", "not-delivered"], needsConversationId: true, rank: 5, note: "用已知会话 URL 重新打开标签页。没有 id 不可执行;绝不退化为新建会话。", }, { id: "relaunch-browser", effect: "browser.launch", allowedKinds: ["browser-gone", "page-unresponsive"], episodeBudget: 1, rank: 6, note: "重新拉起自动化浏览器。同一 episode 最多一次,跨 kind 不恢复预算。", }, { id: "resend", effect: "message.send", allowedKinds: ["not-delivered"], requiresReconcile: true, episodeBudget: 1, rank: 7, note: "重发原消息。仅 reconciled 且已证明 NOT_DELIVERED;整个原发送操作最多一次。", }, { id: "give-up", effect: "none", allowedKinds: ["page-unresponsive", "thinking-placeholder", "stream-frozen", "browser-gone", "blocked", "not-delivered"], terminal: true, rank: 999, note: "终态:结构化错误 + recovery trace + Canary 观察事件;新操作依赖新的显式调用。", }, ] as const satisfies readonly RecoveryActionDef[]; export type RecoveryActionId = (typeof RECOVERY_ACTIONS)[number]["id"]; /** 单一权威列表(cycle.ts 的 STALL_ACTIONS 由此派生)。 */ export const RECOVERY_ACTION_IDS: readonly RecoveryActionId[] = RECOVERY_ACTIONS.map((a) => a.id); /* ------------------------------------------------------------------ */ /* Reload safety verdict(DoD 4) */ /* ------------------------------------------------------------------ */ export type DeliveryState = | "no-submit-in-flight" | "delivery-reconciled" | "submit-in-flight" | "delivery-unknown" | "reconciliation-failed"; export interface ReloadSafetyVerdict { ok: boolean; reason: string; } /** * 只允许 NO_SUBMIT_IN_FLIGHT 或 DELIVERY_RECONCILED。其余一律拒绝: * 提交结果未知时重载页面会毁掉判断"到底有没有发送成功"的证据。 */ export function reloadSafetyVerdict(state: DeliveryState): ReloadSafetyVerdict { switch (state) { case "no-submit-in-flight": return { ok: true, reason: "无在途提交,重载安全" }; case "delivery-reconciled": return { ok: true, reason: "交付状态已 reconcile" }; case "submit-in-flight": return { ok: false, reason: "提交在途——重载会毁掉交付判定证据(destructive observation)" }; case "delivery-unknown": return { ok: false, reason: "交付未知——禁止重载(destructive observation)" }; case "reconciliation-failed": return { ok: false, reason: "reconciliation 失败——禁止重载" }; } } /* ------------------------------------------------------------------ */ /* RecoveryEpisode(DoD 6/7/10/11) */ /* ------------------------------------------------------------------ */ export interface RecoveryEpisodeOptions { /** 触发这次恢复的顶层操作描述,如 "ask" / "relay round 3"。 */ operation: string; /** 原操作对应的受保护副作用(如 message.send);观测失败不产生新授权。 */ originalOperation?: SideEffectId | null; /** 整个 episode 的单调 deadline(kind 振荡不得刷新它)。 */ deadlineMs?: number; now?: () => number; } export interface RecoveryEpisodeSummary { id: string; operation: string; originalOperation: SideEffectId | null; startedAt: number; deadline: number; terminal: boolean; giveUpReason: string | null; kindAttempts: Record; budgetUsed: Record; trail: readonly string[]; lastDeliveryState: DeliveryState | null; } 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 RecoveryEpisode { readonly id: string; readonly operation: string; readonly originalOperation: SideEffectId | null; readonly startedAt: number; readonly deadline: number; /** kind-local 阶梯计数:跨 kind 可重置(重置的是局部阶梯,不是全局预算)。 */ private readonly kindAttempts = new Map(); /** episode-global 预算消耗:绝不因 kind 改变而重置。 */ private readonly budgetUsed = new Map(); private readonly trail: string[] = []; terminal = false; giveUpReason: string | null = null; lastDeliveryState: DeliveryState | null = null; constructor(options: RecoveryEpisodeOptions, now = Date.now()) { this.id = `ep-${randomHex()}`; this.operation = options.operation; this.originalOperation = options.originalOperation ?? null; this.startedAt = now; this.deadline = now + (options.deadlineMs ?? 30 * 60_000); } attemptsFor(kind: StallKind): number { return this.kindAttempts.get(kind) ?? 0; } recordAttempt(kind: StallKind): void { this.kindAttempts.set(kind, (this.kindAttempts.get(kind) ?? 0) + 1); } /** 跨 kind 只重置局部阶梯;全局预算不在这个 Map 里,天然不受影响。 */ resetKindStaircase(kind: StallKind): void { this.kindAttempts.delete(kind); } budgetLeft(actionId: RecoveryActionId): number | null { const def = RECOVERY_ACTIONS.find((a) => a.id === actionId); if (!def || def.episodeBudget === undefined) return null; return def.episodeBudget - (this.budgetUsed.get(actionId) ?? 0); } /** * 动作准入(DoD 1/2/4/5/7/8/11): * - episode 已 terminal → 全部拒绝(absorbing) * - 动作不在注册表 → 拒绝 * - 该 kind 不在 allowedKinds → 拒绝 * - requiresReconcile 且交付状态不满足 → 拒绝 * - needsConversationId 且无会话 id → 拒绝 * - 全局预算耗尽 → 拒绝 */ canRun(actionId: RecoveryActionId, context: { kind: StallKind; hasConversationUrl: boolean; deliveryState: DeliveryState | null; }): { ok: boolean; reason: string } { if (this.terminal) { return { ok: false, reason: `episode 已终态(${this.giveUpReason ?? "give-up"}),不再接受任何 recovery 动作` }; } const def = RECOVERY_ACTIONS.find((a) => a.id === actionId); if (!def) return { ok: false, reason: `动作 ${actionId} 不在 Recovery Registry——注册表外动作禁止执行` }; if (!def.allowedKinds.includes(context.kind)) { return { ok: false, reason: `${actionId} 不适用于 stall kind ${context.kind}` }; } if (def.requiresReconcile) { const safe = reloadSafetyVerdict(context.deliveryState ?? "delivery-unknown").ok; if (!safe) { return { ok: false, reason: `${actionId} 要求交付状态已 reconcile,当前是 ${context.deliveryState ?? "未知"}——拒绝`, }; } } if (def.needsConversationId && !context.hasConversationUrl) { return { ok: false, reason: `${actionId} 需要已知会话 id,当前没有——且绝不退化为新建会话` }; } if (def.episodeBudget !== undefined) { const left = this.budgetLeft(actionId); if (left !== null && left <= 0) { return { ok: false, reason: `${actionId} 的 episode 全局预算已耗尽(1/${def.episodeBudget}),kind 改变不恢复预算` }; } } return { ok: true, reason: "准入" }; } /** 执行一个动作前调用;返回是否获准消耗(预算)。 */ consume(actionId: RecoveryActionId): boolean { const def = RECOVERY_ACTIONS.find((a) => a.id === actionId); if (def?.episodeBudget !== undefined) { const left = this.budgetLeft(actionId); if (left !== null && left <= 0) return false; } this.budgetUsed.set(actionId, (this.budgetUsed.get(actionId) ?? 0) + 1); return true; } record(actionId: RecoveryActionId, note = ""): void { this.trail.push(note ? `${actionId} ${note}` : actionId); } markTerminal(reason: string): void { this.terminal = true; this.giveUpReason = reason; } summary(): RecoveryEpisodeSummary { return { id: this.id, operation: this.operation, originalOperation: this.originalOperation, startedAt: this.startedAt, deadline: this.deadline, terminal: this.terminal, giveUpReason: this.giveUpReason, kindAttempts: Object.fromEntries(this.kindAttempts), budgetUsed: Object.fromEntries(this.budgetUsed), trail: this.trail.slice(), lastDeliveryState: this.lastDeliveryState, }; } }