/** * Cycle policy: when to keep going, when to stop, and what to do when the page * or the network misbehaves. * * This module holds no I/O and no browser handle — it is a pure decision layer, * so the rules can be tested offline and reviewed independently of the runner. * * Two ladders live here: * * 1. Boundaries. The relay must never run forever. Every stop condition is * explicit and independent, so a bug in one cannot defeat the others. * * 2. Stall recovery. A hung page has a defined escalation path with a bounded * number of attempts, ending in a clear, recoverable failure instead of an * indefinite wait. The ChatGPT conversation lives on the server, so reload * and reopen are cheap and lose nothing. */ export interface BoundaryConfig { /** Hard cap on relay rounds. */ maxRounds: number; /** Hard cap on wall-clock time for the whole cycle. */ maxWallClockMs: number; /** Stop after this many consecutive round failures (execution or push). */ maxConsecutiveFailures: number; /** Stop after this many rounds in a row that changed no file. */ maxNoProgressRounds: number; /** Stop when ChatGPT repeats the same plan this many times. */ maxIdenticalPlans: number; /** Push to git after every successful round. */ pushOnEveryRound: boolean; /** Stop after this many consecutive push failures. */ maxPushFailures: number; } export const DEFAULT_BOUNDARIES: BoundaryConfig = { maxRounds: 8, maxWallClockMs: 90 * 60 * 1000, maxConsecutiveFailures: 2, maxNoProgressRounds: 3, maxIdenticalPlans: 2, pushOnEveryRound: true, maxPushFailures: 2, }; /** * What kind of round was this? * * Needed because "changed no files" only means stagnation for a round that was * supposed to change files. An info round gathers source that was missing and * touches nothing by design; counting it as stagnation stops a healthy loop * after three perfectly good information requests. */ export type RoundKind = "work" | "info" | "terminal"; export interface RoundRecord { index: number; startedAt: number; roundKind: RoundKind; /** DECISION as reported by ChatGPT. */ decision: string; taskId: string | null; /** Files the round actually changed (from git status). */ changedFiles: string[]; /** Hash/identity of the plan, used to spot repeats. */ planKey: string; /** Did execution complete without an error? */ ok: boolean; /** Did the push succeed (null when pushing is disabled)? */ pushed: boolean | null; failure?: string; } export interface BoundaryVerdict { stop: boolean; reason: string; /** Which rule fired, for logging and tests. */ rule: string; } /** * Evaluate every stop condition. Order matters only for the message the operator * sees first; all of them are checked, and any one of them is sufficient. */ export function checkBoundaries( rounds: RoundRecord[], config: BoundaryConfig, now = Date.now(), ): BoundaryVerdict { if (rounds.length === 0) return { stop: false, reason: "", rule: "none" }; const last = rounds[rounds.length - 1]; // 1. ChatGPT said it is finished, or that it is stuck. if (last.decision === "done") return { stop: true, reason: "ChatGPT 判定任务完成 (DECISION=done)", rule: "decision-done" }; if (last.decision === "blocked") { return { stop: true, reason: "ChatGPT 报告受阻 (DECISION=blocked),需要人工介入", rule: "decision-blocked" }; } // 2. Hard caps. if (rounds.length >= config.maxRounds) { return { stop: true, reason: `达到轮次上限 ${config.maxRounds}`, rule: "max-rounds" }; } const elapsed = now - rounds[0].startedAt; if (elapsed >= config.maxWallClockMs) { return { stop: true, reason: `达到时间上限 ${Math.round(config.maxWallClockMs / 60000)} 分钟(实际 ${Math.round(elapsed / 60000)} 分钟)`, rule: "max-wall-clock", }; } // 3. Repeated failures. let consecutiveFailures = 0; for (let i = rounds.length - 1; i >= 0 && !rounds[i].ok; i -= 1) consecutiveFailures += 1; if (consecutiveFailures >= config.maxConsecutiveFailures) { return { stop: true, reason: `连续 ${consecutiveFailures} 轮执行失败:${last.failure ?? "未知原因"}`, rule: "consecutive-failures", }; } // 4. Push failures (a broken push means ChatGPT is reading stale code). let pushFailures = 0; for (let i = rounds.length - 1; i >= 0 && rounds[i].pushed === false; i -= 1) pushFailures += 1; if (config.pushOnEveryRound && pushFailures >= config.maxPushFailures) { return { stop: true, reason: `连续 ${pushFailures} 轮推送 git 失败,ChatGPT 读到的是过期代码`, rule: "push-failed" }; } // 5. No progress. // // Only mutation-expected work rounds count. `no-file-change` is not a // universal progress metric: info rounds exist precisely to produce no file // changes, and terminal rounds have nothing to run. let noProgress = 0; for ( let i = rounds.length - 1; i >= 0 && rounds[i].ok && rounds[i].roundKind === "work" && rounds[i].changedFiles.length === 0; i -= 1 ) { noProgress += 1; } if (noProgress >= config.maxNoProgressRounds) { return { stop: true, reason: `连续 ${noProgress} 个应当改动文件的轮次没有产生任何改动,判定为原地打转`, rule: "no-progress", }; } // 6. Same plan served twice in a row. let repeats = 1; for (let i = rounds.length - 2; i >= 0 && rounds[i].planKey === last.planKey; i -= 1) repeats += 1; if (repeats >= config.maxIdenticalPlans) { return { stop: true, reason: `ChatGPT 连续 ${repeats} 次给出相同方案`, rule: "identical-plans" }; } return { stop: false, reason: "", rule: "none" }; } /* ------------------------------------------------------------------ */ /* Stall recovery */ /* ------------------------------------------------------------------ */ export type StallKind = /** CDP evaluate / state read is failing or timing out. */ | "page-unresponsive" /** The reply never leaves the "Thinking…" placeholder. */ | "thinking-placeholder" /** Text stopped changing while the page still claims to be streaming. */ | "stream-frozen" /** The browser or its debug endpoint is gone. */ | "browser-gone" /** The page shows a challenge / limit / logout notice. */ | "blocked" /** * The message never reached the server. * * Distinct from every other kind because the user-message count proves nothing * was delivered, so retrying cannot duplicate the send. That is what makes * "resend" a decidable action here rather than a gamble. */ | "not-delivered"; import { RECOVERY_ACTION_IDS, type RecoveryActionId } from "./recovery.ts"; /** * Single source of recovery truth (A13, verdict #13 DoD 1): the registry lives in * recovery.ts and this list is DERIVED from it, never hand-synced. A second * authoritative list is exactly the drift this repository keeps finding, so the * structural test asserts STALL_ACTIONS equals RECOVERY_ACTION_IDS. */ export const STALL_ACTIONS: readonly RecoveryActionId[] = RECOVERY_ACTION_IDS; export type StallAction = RecoveryActionId; // `rotate-conversation` used to be here, and it opened a fresh conversation whenever // a reply stalled. That is a second creation path, unguarded by the creation budget // and unaccounted for -- and it let automatic recovery manufacture the very act it // is supposed to ask about. Rotation now stops at a recommendation: the ladder can // say a conversation looks unusable, and only a person can decide to start another. export interface StallContext { kind: StallKind; /** How many recovery attempts have already been made for this stall. */ attempt: number; /** Wall-clock time spent on this stall. */ elapsedMs: number; /** Is the conversation URL known (so reopening is possible)? */ hasConversationUrl: boolean; /** Are we willing to relaunch the browser (false inside a relay round)? */ allowRelaunch: boolean; /** * What the operation's own retry policy permits. * * Passed in rather than decided here, because the policy lives in the side-effect registry and * a second copy of it in this file is how the two would drift. `sideEffects.retryDecision()` * produces this; a ladder branch that wants to retry has to say what the registry told it. */ retryVerdict?: { ok: boolean; reason: string }; } export interface StallDecision { action: StallAction; reason: string; /** Suggested pause before acting, so recovery is not a tight loop. */ backoffMs: number; /** Human-readable note for the transcript. */ note: string; } /** * The escalation path for a misbehaving page. * * Every branch is bounded: there is always a finite number of attempts before * "give-up", so a stall can delay a cycle but never hang it. */ export function planStallAction(context: StallContext): StallDecision { const { kind, attempt, elapsedMs, hasConversationUrl, allowRelaunch, retryVerdict } = context; const backoff = Math.min(30_000, 2_000 * 2 ** Math.min(attempt, 4)); if (kind === "blocked") { return { action: "give-up", reason: "页面处于验证/限额/登出状态", backoffMs: 0, note: "不要重试,这类状态重试只会加重问题。请人工处理后重新 /brb start。", }; } if (kind === "browser-gone") { if (allowRelaunch && attempt === 0) { return { action: "relaunch-browser", reason: "调试端口不可达", backoffMs: 3_000, note: "重新拉起自动化浏览器;登录态在 profile 里,不需要重新登录。", }; } return { action: "give-up", reason: "浏览器无法恢复", backoffMs: 0, note: "运行 /brb launch 手动确认浏览器能起来,再重新开始。", }; } if (kind === "not-delivered") { // A send that provably did not arrive can simply be sent again, so this ladder spends its // attempts on the page conditions that make a send fail silently rather than on the message // itself. // // But "provably did not arrive" is a claim about the operation's outcome, and the registry // owns what may be done about it. The ladder asks rather than assuming, so changing // `message.send`'s policy in the registry changes this behaviour without editing this file. // A ladder that decided for itself would be a second, quieter copy of the policy. if (retryVerdict && !retryVerdict.ok) { return { action: "give-up", reason: "该操作的策略不允许重试", backoffMs: 0, note: retryVerdict.reason, }; } if (attempt === 0) { return { action: "activate-tab", reason: "消息没有送达", backoffMs: 1_000, note: "把标签页调到前台后重发。消息没有落库,所以重发不是重复发送。", }; } if (attempt === 1) { return { action: "reload-tab", reason: "重发仍未送达", backoffMs: 3_000, note: "页面可能处于半死状态,重载后重发。", }; } if (attempt === 2 && hasConversationUrl) { return { action: "reopen-conversation", reason: "重载后仍未送达", backoffMs: 5_000, note: "重新导航回该会话再重发。", }; } return { action: "give-up", reason: "反复无法送达", backoffMs: 0, note: "停止重发,避免把一个持续失败的发送变成大量重复尝试。请人工检查页面与网络。", }; } if (kind === "thinking-placeholder") { if (attempt === 0) { return { action: "press-stop", reason: "回复卡在「正在思考」占位符", backoffMs: 2_000, note: "先点「停止回答」中断这次生成。", }; } if (attempt === 1) { return { action: "nudge", reason: "已中断但仍无正文", backoffMs: 5_000, note: "发一条很短的催促:只输出方案正文,不要思考过程。", }; } return { action: "give-up", reason: "多次尝试后仍无法取得正文", backoffMs: 0, note: "这个会话可能已经不适合继续使用(轮换建议,不是自动动作)。" + "桥不会自己新建会话——那会消耗你的账号资源,而且你才知道这条会话还值不值得留。" + "要继续请显式执行 brb 新会话,或先 brb 会话 看清现状。", }; } if (kind === "stream-frozen") { // A frozen stream is usually just a slow finish; waiting is cheap and // reloading mid-stream would lose the reply. if (elapsedMs < 180_000) { return { action: "retry-read", reason: "流式输出停滞但仍在允许等待窗口内", backoffMs: 5_000, note: "继续等待,不要动页面。", }; } return { action: attempt === 0 ? "press-stop" : "nudge", reason: `流式停滞已超过 ${Math.round(elapsedMs / 60000)} 分钟`, backoffMs: 5_000, note: "停止本次生成,取回已产出的部分。", }; } // page-unresponsive const ladder: StallAction[] = ["retry-read", "activate-tab", "reload-tab", "reopen-conversation"]; const action = hasConversationUrl ? ladder[Math.min(attempt, ladder.length - 1)] : ladder[Math.min(attempt, ladder.length - 2)]; const notes: Record = { "retry-read": "重新读一次页面状态,很多时候只是一次偶发超时。", "activate-tab": "把标签页调到前台再读;后台标签页的渲染会被降级。", "reload-tab": "重载标签页。会话在服务端,重载不会丢失任何对话内容。", "reopen-conversation": "用会话 URL 重新打开标签页,绕开已经卡死的渲染进程。", }; if (attempt >= ladder.length) { return { action: "give-up", reason: "页面无响应的恢复阶梯已用尽", backoffMs: 0, note: "用 /brb doctor 看一眼,或手动刷新浏览器窗口后重新开始。", }; } return { action, reason: `页面无响应(第 ${attempt + 1} 次处置)`, backoffMs: backoff, note: notes[action] ?? "", }; } /** * Map a thrown error message onto a stall kind, or null when the failure is NOT * a page problem. * * This separation is load-bearing: the plan-review flagged that wrapping the whole * relay in the stall ladder would swallow real failures — a protocol error, a code * bug, or a blocked account would be retried forever. Returning null means "let the * caller handle it normally", so only genuine page stalls enter the ladder. */ export function classifyStallError(message: string): StallKind | null { const text = (message || "").toLowerCase(); if (!text) return null; // Account / page state problems: never retried by the ladder. if (/logged out|登录页|未登录|已登出|verify you are human|人机验证|上限|rate limit|限流|unusual activity/.test(text)) return "blocked"; // Transport is gone. if (/调试端口|devtools|websocket|econnrefused|target closed|connection closed|浏览器/.test(text)) return "browser-gone"; // The question never reached the server. Checked before the placeholder rule: // a lost send also leaves the page showing "thinking", and blaming the // placeholder sent a real investigation down the wrong path. // "the composer still had text" and "the message count never moved" are the same // fact observed at two points, and they want the same recovery: send it again. if (/没有送达|没有发出去|not delivered|delivery/.test(text)) return "not-delivered"; // The reply never leaves the thinking placeholder. if (/正在思考|placeholder/.test(text)) return "thinking-placeholder"; // Text stopped moving while the page still claims to be streaming. if (/still streaming|截断|incomplete|未闭合/.test(text)) return "stream-frozen"; // Page is alive but not reacting. if (/timed out|timeout|超时|没有发出去|composer/.test(text)) return "page-unresponsive"; return null; } /** * Advance the stall bookkeeping and produce the decision for this failure. * * Consecutive failures of the *same* kind escalate; a different kind restarts the * ladder, so one unrelated hiccup does not push an unrelated stall to give-up. */ export interface StallState { kind: StallKind | null; attempt: number; startedAt: number; } export function advanceStall( state: StallState, kind: StallKind, options: { hasConversationUrl: boolean; allowRelaunch: boolean; /** The operation's retry policy, from the registry. See `StallContext.retryVerdict`. */ retryVerdict?: { ok: boolean; reason: string }; now?: number; }, ): { decision: StallDecision; state: StallState } { const now = options.now ?? Date.now(); const sameKind = state.kind === kind; const attempt = sameKind ? state.attempt + 1 : 0; const startedAt = sameKind ? state.startedAt : now; const decision = planStallAction({ kind, attempt, elapsedMs: now - startedAt, hasConversationUrl: options.hasConversationUrl, allowRelaunch: options.allowRelaunch, retryVerdict: options.retryVerdict, }); return { decision, state: { kind, attempt, startedAt } }; } /** Reset after a round that made real progress. */ export function resetStall(): StallState { return { kind: null, attempt: 0, startedAt: 0 }; } /** Stable identity for a plan, used to spot ChatGPT repeating itself. */ export function planKey(plan: { decision: string; taskId: string | null; steps: string[]; files: string[] }): string { const core = [plan.decision, plan.taskId ?? "", ...plan.steps, ...plan.files].join("|"); let hash = 0; for (let i = 0; i < core.length; i += 1) { hash = (hash * 31 + core.charCodeAt(i)) | 0; } return `${(hash >>> 0).toString(16)}-${core.length}`; } /** One-line summary of why the cycle stopped, for logs. */ export function describeVerdict(verdict: BoundaryVerdict): string { return verdict.stop ? `停止:${verdict.reason}(规则 ${verdict.rule})` : "继续"; }