From d0dec5175751c344c9b95540db260b31025a5e63 Mon Sep 17 00:00:00 2001 From: METAL-H Date: Fri, 30 Oct 2026 02:18:57 +0800 Subject: [PATCH 1/4] feat(opencode): restore doom loop detection as internal plugin for v2 --- packages/core/src/plugin/doom-loop.ts | 240 +++++++++++ .../core/src/plugin/doom-loop/detector.ts | 372 ++++++++++++++++++ packages/core/src/plugin/doom-loop/fold.ts | 89 +++++ packages/core/src/plugin/internal.ts | 2 + 4 files changed, 703 insertions(+) create mode 100644 packages/core/src/plugin/doom-loop.ts create mode 100644 packages/core/src/plugin/doom-loop/detector.ts create mode 100644 packages/core/src/plugin/doom-loop/fold.ts diff --git a/packages/core/src/plugin/doom-loop.ts b/packages/core/src/plugin/doom-loop.ts new file mode 100644 index 000000000000..f61a101484a2 --- /dev/null +++ b/packages/core/src/plugin/doom-loop.ts @@ -0,0 +1,240 @@ +export * as DoomLoopPlugin from "./doom-loop.js" + +import { define } from "@opencode/plugin/effect/plugin" +import type { Session } from "@opencode/schema/session" +import type { SessionEvent } from "@opencode/schema/session-event" +import { Effect, Scope, Stream } from "effect" +import { Tool } from "../tool.js" +import { DoomLoopDetector } from "./doom-loop/detector.js" +import { DoomLoopFold } from "./doom-loop/fold.js" + +// Session state is rebuilt lazily from durable history on first tool-hook touch (fold), so a +// process restart continues A/C streaks instead of silently zeroing them. The one fact history +// cannot rebuild is a tripped circuit breaker (B's streak restarts at zero), so it is persisted +// in plugin KV and re-armed only by a real user message. +// +// Because every entry can be re-folded, the sessions map is a pure cache: past +// MAX_TRACKED_SESSIONS the least recently touched entry is dropped and re-folded on its next +// touch. Every eviction loss is miss-direction — bStreak restarts at zero exactly as after a +// restart, and calls accounted since the last durable write replay short. The breaker survives +// in KV, and an executing session touches the map on every hook, so the LRU tail is always a +// session no hook is touching. + +const DEFAULT_MAX_TRACKED_SESSIONS = 64 +let maxTrackedSessions = DEFAULT_MAX_TRACKED_SESSIONS + +// Test seam: suites lower the cap to exercise eviction without booting 65 sessions. Call with no +// argument to restore the default. +export function setMaxTrackedSessions(limit: number = DEFAULT_MAX_TRACKED_SESSIONS) { + maxTrackedSessions = limit +} + +type Tracked = + | SessionEvent.Deleted + | SessionEvent.InboxEnqueued + | SessionEvent.Step.Ended + | SessionEvent.Compaction.Ended + | SessionEvent.Execution.Succeeded + | SessionEvent.Execution.Failed + | SessionEvent.Execution.Interrupted + +interface Entry { + state: DoomLoopDetector.SessionState +} + +const circuitKey = (sessionID: Session.ID) => `circuit:${sessionID}` + +export const Plugin = define({ + id: "opencode.doom.loop", + effect: Effect.fn(function* (ctx) { + const scope = yield* Scope.Scope + const sessions = new Map>() + + const folded = (sessionID: Session.ID): Promise => + Effect.runPromise( + Effect.gen(function* () { + const entry: Entry = { state: DoomLoopDetector.state() } + const messages = yield* ctx.session.context({ sessionID }) + DoomLoopFold.fold(entry.state, messages) + if ((yield* ctx.storage.get(circuitKey(sessionID))) === true) entry.state.circuitBroken = true + return entry + }), + // A history read failure must not fail the tool call: fall back to a fresh state (the + // pre-fold behavior) rather than dropping detection entirely. + ).catch((): Entry => ({ state: DoomLoopDetector.state() })) + + // The promise enters the map synchronously so concurrent first touches share one fold. + const session = (sessionID: Session.ID) => { + const existing = sessions.get(sessionID) + if (existing) { + // LRU touch: Map.set on an existing key does not reorder, so reinsert at the tail. + sessions.delete(sessionID) + sessions.set(sessionID, existing) + return existing + } + const created = folded(sessionID) + sessions.set(sessionID, created) + // Eviction is a bare delete: the KV breaker key belongs to the session lifecycle, not to + // this cache, and everything else re-folds from durable history on the next touch. + while (sessions.size > maxTrackedSessions) { + const oldest = sessions.keys().next().value + if (oldest === undefined) break + sessions.delete(oldest) + } + return created + } + + // Event-time mutations chain onto the in-flight fold so they apply after it settles. Sessions + // never touched by a hook are not created here: a fresh state is already the zeroed state. + const mutate = (sessionID: Session.ID, fn: (state: DoomLoopDetector.SessionState) => void) => { + const existing = sessions.get(sessionID) + if (existing) + sessions.set( + sessionID, + existing.then((entry) => { + fn(entry.state) + return entry + }), + ) + } + + yield* ctx.tool.hook("execute.before", (event) => + Effect.gen(function* () { + // The outer Code Mode tool is built per snapshot and re-enters with real tool names; its + // own `execute` call carries no observable signature. Skipping by name also exempts an + // MCP/custom tool literally named `execute` — a false-negative, conservative direction. + if (event.tool === "execute") return + const current = yield* Effect.promise(() => session(event.sessionID)) + const sigHash = DoomLoopDetector.signature(event.tool, event.input) + const verdict = DoomLoopDetector.beforeCall(current.state, { + tool: event.tool, + sigHash, + messageID: event.messageID, + }) + if (verdict.kind === "pass") return + if (verdict.kind === "circuit") { + yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore) + // Hard stop: interrupt the drain without awaiting — the fork outlives the aborted step + // fiber. The handoff message is best-effort and expected to be lost with the step. + yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) + return yield* new Tool.Error({ message: verdict.message, metadata: DoomLoopDetector.blockedMetadata }) + } + const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix[verdict.signal]}` + // host.ts maps BlockedError/CorrectedError to Tool.Error and keeps a plain rejection a + // defect; stacking our copy on the mapped message preserves the "why blocked + what next" + // guidance for both typed outcomes. Deliberately no inner `error`: toSessionError unwraps + // to the innermost error's message when one is attached, which would discard our copy. + // The blockedMetadata marker persists on the error part so the fold skips this call — it + // never executed, so the live path never accounted it. + return yield* ctx.permission + .assert({ + sessionID: event.sessionID, + agent: event.agent, + action: "doom_loop", + resources: [resource], + save: [resource], + source: { type: "tool", messageID: event.messageID, id: event.id }, + }) + .pipe( + Effect.mapError( + (error) => + new Tool.Error({ + message: `${verdict.message}\n\n${error.message}`, + metadata: DoomLoopDetector.blockedMetadata, + }), + ), + ) + }), + ) + + yield* ctx.tool.hook("execute.after", (event) => + Effect.gen(function* () { + if (event.tool === "execute") return + const current = yield* Effect.promise(() => session(event.sessionID)) + const sigHash = DoomLoopDetector.signature(event.tool, event.input) + if (event.status === "error") { + // Failure events feed signal C only — they never enter A/B, or a run of failures with + // distinct messages would count as identical empty outputs. The warning lands on the + // error message because a failed call has no result content. + // Deviation from ⑦, declared: error events carry no metadata, so a background shell's + // launch failure cannot be distinguished and is counted into C. Deliberately conservative + // direction (detect more; C still only ever asks, never hard-blocks silently). + const warning = DoomLoopDetector.recordFailed(current.state, { + tool: event.tool, + errorText: event.error.message, + validation: event.error.message.startsWith("Invalid arguments for tool"), + }) + if (warning !== undefined) event.error.message = `${event.error.message}\n\n${warning}` + return + } + // Unobserved layer (background shell — registered under the name "shell"; question): + // A/B/C abstain — the receipt text is fixed, so counting it would false-positive signal A + // immediately. + const unobserved = + event.tool === "question" || + (event.tool === "shell" && event.result.metadata !== undefined && event.result.metadata.status === "running") + if (unobserved) { + DoomLoopDetector.recordUnobserved(current.state, sigHash) + return + } + // Hash and account before touching content so our own text never feeds back in. + const text = DoomLoopDetector.outputText(event.result.content, event.result.output) + const warning = DoomLoopDetector.recordCompleted(current.state, { tool: event.tool, sigHash, text }) + if (warning === undefined) return + // Prepend rather than append: the runner's ToolOutput.truncate keeps the head of oversized + // outputs (tool-output.ts), so a tail warning would be cut exactly on the huge outputs of + // looping MCP-style tools. + const existing = event.result.content + event.result = { + ...event.result, + content: [ + { type: "text" as const, text: warning }, + ...(typeof existing === "string" ? [{ type: "text" as const, text: existing }] : (existing ?? [])), + ], + } + }), + ) + + yield* ctx.event.subscribe().pipe( + Stream.filter( + (event): event is Tracked => + event.type === "session.deleted" || + event.type === "session.inbox.enqueued" || + event.type === "session.step.ended" || + event.type === "session.compaction.ended" || + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted", + ), + Stream.runForEach((event) => + Effect.gen(function* () { + const sessionID = event.data.sessionID + if (event.type === "session.deleted") { + sessions.delete(sessionID) + yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore) + return + } + if (event.type === "session.inbox.enqueued") { + // Only real user messages re-arm the breaker; our own synthetic injections must not. + // The persisted key clears even when this process never folded the session. + if (event.data.item.type !== "user") return + yield* ctx.storage.remove(circuitKey(sessionID)).pipe(Effect.ignore) + mutate(sessionID, DoomLoopDetector.userMessage) + return + } + if (event.type === "session.step.ended") { + if (event.data.files === undefined || event.data.files.length === 0) return + mutate(sessionID, DoomLoopDetector.filesChanged) + return + } + if (event.type === "session.compaction.ended") { + mutate(sessionID, DoomLoopDetector.compacted) + return + } + mutate(sessionID, DoomLoopDetector.tighten) + }), + ), + Effect.forkScoped({ startImmediately: true }), + ) + }), +}) diff --git a/packages/core/src/plugin/doom-loop/detector.ts b/packages/core/src/plugin/doom-loop/detector.ts new file mode 100644 index 000000000000..12803395cc6f --- /dev/null +++ b/packages/core/src/plugin/doom-loop/detector.ts @@ -0,0 +1,372 @@ +export * as DoomLoopDetector from "./detector.js" + +// Pure doom-loop detection core: no Effect, no opencode imports, portable across hosts. +// Four detectors: +// - Signal A: per-signature (tool + canonical input) identical-output streak. Warn 3, block 6. +// - Signal B: global line-level information novelty. Warn 3/5, surgical block 8 (needs A streak >= 3 +// as corroboration), circuit breaker 12 (hard stop until the user speaks). +// - Signal C: per-tool failure-equivalence streak (volatile tokens normalized before hashing). +// Warn 4, block 6. Interleaved successes of other tools do not reset it; a success of the same +// tool does. +// - Same-message repeat guard: identical (tool, input) emitted 3 times within one assistant message +// asks exactly once (== 3, not >= 3, so later high-tier prompts cannot be starved by it). +// Thresholds are accounted in after hooks and read in before hooks: "block at N" means the call +// emitted after the Nth accounting (the N+1th emission) is the one challenged in before. + +const A_WARN = 3 +const A_BLOCK = 6 +const B_WARN_LOW = 3 +const B_WARN_HIGH = 5 +const B_BLOCK = 8 +const B_CIRCUIT = 12 +const B_MIN_LINES = 3 +const C_WARN = 4 +const C_BLOCK = 6 +const GUARD_TRIGGER = 3 +const UNOBSERVED_PROMPT = 6 +const LINE_LIMIT = 10_000 +const LINE_CAP = 1024 +const TIGHTENED_LINE_KEEP = 1_000 +// Per-session cap for the signature (A) and unobserved maps, each bounded independently. An +// entry is ~100 bytes, so a session worst-cases at ~400KB per map. +const SIGNATURE_LIMIT = 4_096 + +const FNV_OFFSET = 0xcbf29ce484222325n +const FNV_PRIME = 0x100000001b3n +const FNV_MASK = 0xffffffffffffffffn + +export function fnv64(text: string): string { + let hash = FNV_OFFSET + for (let index = 0; index < text.length; index++) { + hash ^= BigInt(text.charCodeAt(index)) + hash = (hash * FNV_PRIME) & FNV_MASK + } + return hash.toString(16).padStart(16, "0") +} + +// Canonical JSON with recursively sorted keys so semantically identical inputs hash identically +// regardless of key order. Bash AST normalization is deliberately omitted (decided): `ls` vs +// `ls -la` are distinct signatures and escape A, which signal B covers instead. +export function canonical(value: unknown): string { + if (value === null || typeof value !== "object") { + const primitive = JSON.stringify(value === undefined ? null : value) + return primitive === undefined ? "null" : primitive + } + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]` + const record = value as Record + const body = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`) + .join(",") + return `{${body}}` +} + +export const signature = (tool: string, input: unknown) => fnv64(`${tool}:${canonical(input)}`) + +// Volatile tokens (ids, durations, timestamps, plain numbers) collapse to placeholders so that +// "in 0.03s" and "in 0.05s" normalize to the same line and the same failure identity. +const normalizeVolatile = (line: string): string => + line + .replace(/[0-9a-f]{8,}/gi, "") + .replace(/\b\d+(?:\.\d+)?(?:ns|us|ms|s|m|h)\b/g, "") + .replace(/\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g, "") + .replace(/\b\d+(?:\.\d+)?\b/g, "") + +const SEPARATOR = /^[-=_*~ ]{3,}$/ + +// Lines enter the global novelty store normalized and capped at 1KB each (memory bound). +const contentLines = (text: string): string[] => + text + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !SEPARATOR.test(line)) + .map((line) => normalizeVolatile(line).slice(0, LINE_CAP)) + +const stringify = (value: unknown): string => { + if (typeof value === "string") return value + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +// Mirrors the runtime content normalization: a tool result carries either string content, content +// parts, or (for output-only tools) falls back to the stringified machine output. Hashing must use +// this normalized view so output-only tools do not all hash as one constant empty string. +export function outputText( + content: string | ReadonlyArray<{ readonly type: string; readonly text?: string }> | undefined, + output?: unknown, +): string { + if (typeof content === "string") return content + if (content !== undefined && content.length > 0) + return content + .flatMap((item) => (item.type === "text" && typeof item.text === "string" ? [item.text] : [])) + .join("\n") + return stringify(output) +} + +export const copy = { + warnA: (tool: string, streak: number): string => + `The ${tool} call with these exact arguments has returned byte-identical output ${streak} times in a row. It is not producing new information. Change approach: inspect the actual current state, or ask the user.`, + warnB3: `Your recent tool calls have produced no new information. Before making another call, write down what new information you expect the next call to reveal.`, + warnB5: `These repeated calls keep returning nothing new. Choose one: run a falsification check on your current hypothesis, ask the user for the missing input, or conclude based on the evidence you already have.`, + warnC: (tool: string, streak: number): string => + `The ${tool} tool has failed ${streak} times in a row with the same error. Stop retrying it unchanged. Inspect the target or ask the user before trying again.`, + blockA: (tool: string, streak: number): string => + `This exact ${tool} call has returned identical output ${streak} times in a row and is blocked as a suspected repetition loop. If this repetition is intentional (for example user-requested polling), approve the doom_loop permission to continue.`, + blockB: `Recent calls keep producing nothing new, and this exact call has already returned identical output repeatedly. It is blocked as suspected non-progress. Run a falsification check, ask the user for missing input, or conclude with the evidence at hand.`, + blockC: (tool: string, streak: number): string => + `The ${tool} tool has failed ${streak} times in a row with the same error and is blocked as a suspected retry loop. Inspect the target state or ask the user before trying again.`, + circuit: `Twelve recent tool calls in a row produced no new information. Stop making tool calls entirely. Summarize the current state, the hypotheses you have ruled out, and what input you need from the user, in plain text.`, + guard: (count: number, tool: string): string => + `This exact ${tool} call has already been emitted ${count} times within the current assistant message. If the same invocation is repeating without progress, stop and report to the user instead.`, + guardMerged: (count: number, tool: string, aStreak: number): string => + `This exact ${tool} call has been emitted ${count} times within the current assistant message and returned identical output ${aStreak} times before. Stop repeating it and report to the user instead.`, + unobserved: `This call's progress cannot be observed from the session output (for example a background shell). If you are running it in a loop: write progress to a file and report from it, report the current status directly to the user, or stop and summarize.`, +} as const + +// Resource/save strings are `${tool}:${sigHash}` with a per-signal suffix so an "always" answer on +// one prompt never silently exempts another signal's prompt for the same signature. +export const resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" } as const + +// Texts the live after hook can inject into a persisted result: warnings prepend to a completed +// result's content and append to a failed call's error message. They fire only at fixed streaks, +// so the complete set is enumerable. The fold strips them to hash the same text the live path +// accounted — live hashes before injecting. +export function injectedWarnings(tool: string): ReadonlyArray { + const a = copy.warnA(tool, A_WARN) + return [a, copy.warnB3, copy.warnB5, `${a}\n\n${copy.warnB3}`, `${a}\n\n${copy.warnB5}`] +} + +export const injectedFailureSuffix = (tool: string): string => `\n\n${copy.warnC(tool, C_WARN)}` + +// Marker the plugin attaches to Tool.Errors raised by its before hook. It persists on the error +// part (runner failTool -> Tool.Failed metadata), letting the fold tell a blocked call — never +// executed, never accounted live — apart from a real execution failure. +export const blockedMetadata = { doom_loop: "blocked" } as const + +export const hasBlockedMetadata = (metadata: Record | undefined): boolean => + metadata?.doom_loop === blockedMetadata.doom_loop + +export type Verdict = + | { readonly kind: "pass" } + | { readonly kind: "circuit"; readonly message: string } + | { + readonly kind: "assert" + readonly signal: keyof typeof resourceSuffix + readonly message: string + } + +export interface SignatureEntry { + lastOutputHash: string + streak: number +} + +export interface FailureEntry { + lastFailureHash: string + streak: number +} + +export interface SessionState { + signatures: Map + lines: { set: Set; order: string[] } + bStreak: number + failures: Map + // Only the current message's bucket is retained; a new messageID drops the old one. + emitted: { messageID: string; counts: Map } + unobserved: Map + highTierPrompted: boolean + circuitBroken: boolean +} + +export function state(): SessionState { + return { + signatures: new Map(), + lines: { set: new Set(), order: [] }, + bStreak: 0, + failures: new Map(), + emitted: { messageID: "", counts: new Map() }, + unobserved: new Map(), + highTierPrompted: false, + circuitBroken: false, + } +} + +export interface Call { + readonly tool: string + readonly sigHash: string + readonly messageID: string +} + +// Short-circuit order: circuit breaker -> same-message guard (==3, one-shot) -> unobserved-layer +// one-time prompt -> A >= 6 -> B >= 8 surgical (requires A streak >= 3 corroboration) -> C >= 6. +// The first hit is the only action for this call; a circuit hit hard-stops without asking. +export function beforeCall(current: SessionState, call: Call): Verdict { + if (current.circuitBroken || current.bStreak >= B_CIRCUIT) { + current.circuitBroken = true + return { kind: "circuit", message: copy.circuit } + } + if (current.emitted.messageID !== call.messageID) { + current.emitted = { messageID: call.messageID, counts: new Map() } + } + const emitted = (current.emitted.counts.get(call.sigHash) ?? 0) + 1 + current.emitted.counts.set(call.sigHash, emitted) + if (emitted === GUARD_TRIGGER) { + const entry = current.signatures.get(call.sigHash) + const message = + entry !== undefined && entry.streak >= A_WARN + ? copy.guardMerged(emitted, call.tool, entry.streak) + : copy.guard(emitted, call.tool) + return { kind: "assert", signal: "guard", message } + } + const unobserved = current.unobserved.get(call.sigHash) ?? 0 + if (unobserved >= UNOBSERVED_PROMPT && !current.highTierPrompted) { + current.highTierPrompted = true + return { kind: "assert", signal: "unobserved", message: copy.unobserved } + } + const entry = current.signatures.get(call.sigHash) + if (entry !== undefined && entry.streak >= A_BLOCK) { + return { kind: "assert", signal: "a", message: copy.blockA(call.tool, entry.streak) } + } + if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) { + return { kind: "assert", signal: "b", message: copy.blockB } + } + const failure = current.failures.get(call.tool) + if (failure !== undefined && failure.streak >= C_BLOCK) { + return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) } + } + return { kind: "pass" } +} + +export interface Completed { + readonly tool: string + readonly sigHash: string + readonly text: string +} + +// LRU write for the signature-keyed maps: a write moves the key to the newest position, and the +// oldest entry is evicted past SIGNATURE_LIMIT. Eviction is indistinguishable from the +// output-changed branch — a recurred signature restarts at streak 1 — so the loss is +// miss-direction only and never a false block. A genuinely looping signature re-fires on every +// call and stays hot; only one not seen for 4096 distinct others is dropped. +function lruSet(map: Map, key: string, value: V) { + map.delete(key) + map.set(key, value) + if (map.size <= SIGNATURE_LIMIT) return + const oldest = map.keys().next().value + if (oldest !== undefined) map.delete(oldest) +} + +// Accounts a successful call into A and B. Error events never reach this function: failures feed +// signal C only, so a run of failed calls cannot masquerade as a run of identical empty outputs. +// Returns warning text to append to the model-visible output, if any. +export function recordCompleted(current: SessionState, call: Completed): string | undefined { + // Only a success of the same tool proves the retry loop is over. + current.failures.delete(call.tool) + + const outputHash = fnv64(call.text) + const previous = current.signatures.get(call.sigHash) + const entry: SignatureEntry = + previous !== undefined && previous.lastOutputHash === outputHash + ? { lastOutputHash: outputHash, streak: previous.streak + 1 } + : { lastOutputHash: outputHash, streak: 1 } + lruSet(current.signatures, call.sigHash, entry) + + const warnings: string[] = [] + if (entry.streak === A_WARN) warnings.push(copy.warnA(call.tool, entry.streak)) + + const lines = contentLines(call.text) + // Outputs shorter than 3 lines are exempt from B: one-line receipts (edit/write) never streak. + if (lines.length >= B_MIN_LINES) { + // Novelty counts distinct lines: one new line repeated k times inside a single output is still + // one new fact and must not inflate the count. The ratio keeps the total line count as its + // denominator — "1 new line out of 4" is a decorator variant, not productivity. + const distinct = [...new Set(lines)] + let novel = 0 + for (const line of distinct) { + if (!current.lines.set.has(line)) novel++ + } + const productive = novel >= 2 || novel / lines.length >= 0.5 + if (productive) { + current.bStreak = 0 + } else { + current.bStreak++ + if (current.bStreak === B_WARN_LOW) warnings.push(copy.warnB3) + if (current.bStreak === B_WARN_HIGH) warnings.push(copy.warnB5) + } + for (const line of distinct) addLine(current, line) + } + return warnings.length === 0 ? undefined : warnings.join("\n\n") +} + +function addLine(current: SessionState, line: string) { + if (current.lines.set.has(line)) return + current.lines.set.add(line) + current.lines.order.push(line) + // FIFO eviction of the oldest half at the 10k cap keeps the store bounded. + if (current.lines.order.length > LINE_LIMIT) { + const evicted = current.lines.order.splice(0, LINE_LIMIT / 2) + for (const old of evicted) current.lines.set.delete(old) + } +} + +export interface Failed { + readonly tool: string + readonly errorText: string + // Argument-validation rejections that never executed do not count (OpenClaw argument-validation + // semantics); the model must be free to fix its arguments. + readonly validation: boolean +} + +// Failure equivalence: same tool, same normalized error text counts; a different error means the +// model is making progress and resets the streak. Interleaved successes of other tools (reads) +// deliberately do not reset it. +export function recordFailed(current: SessionState, call: Failed): string | undefined { + if (call.validation) return undefined + const failureHash = fnv64(normalizeVolatile(call.errorText)) + const previous = current.failures.get(call.tool) + const entry: FailureEntry = + previous !== undefined && previous.lastFailureHash === failureHash + ? { lastFailureHash: failureHash, streak: previous.streak + 1 } + : { lastFailureHash: failureHash, streak: 1 } + current.failures.set(call.tool, entry) + if (entry.streak === C_WARN) return copy.warnC(call.tool, entry.streak) + return undefined +} + +// Unobserved layer (background shell, question): A/B/C abstain; only the accounting for the +// one-time high-tier prompt happens here. +export function recordUnobserved(current: SessionState, sigHash: string) { + lruSet(current.unobserved, sigHash, (current.unobserved.get(sigHash) ?? 0) + 1) +} + +// Context discontinuity (compaction): A's per-signature streaks reset but keep their output hashes, +// B's line store and streak reset. C intentionally survives: failure equivalence does not depend on +// context content. +export function compacted(current: SessionState) { + for (const entry of current.signatures.values()) entry.streak = 0 + current.lines.set.clear() + current.lines.order = [] + current.bStreak = 0 +} + +// User message: the circuit breaker's defined escape hatch. B resets, the breaker re-arms; A/C +// survive across user messages (a user interjection must not hand the loop a fresh counter). +export function userMessage(current: SessionState) { + current.bStreak = 0 + current.circuitBroken = false +} + +// Files actually changed on disk: the world-observable escape for B. +export function filesChanged(current: SessionState) { + current.bStreak = 0 +} + +// Execution-end sweep (memory bound): tighten the line store of the session that just finished. +// A/C counters are preserved — they intentionally survive across user messages and executions. +export function tighten(current: SessionState) { + if (current.lines.order.length <= TIGHTENED_LINE_KEEP) return + const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP) + for (const old of evicted) current.lines.set.delete(old) +} diff --git a/packages/core/src/plugin/doom-loop/fold.ts b/packages/core/src/plugin/doom-loop/fold.ts new file mode 100644 index 000000000000..f017d0df4a2d --- /dev/null +++ b/packages/core/src/plugin/doom-loop/fold.ts @@ -0,0 +1,89 @@ +export * as DoomLoopFold from "./fold.js" + +import type { SessionMessage } from "@opencode/schema/session-message" +import { DoomLoopDetector } from "./detector.js" + +// Rebuilds detector state from durable session history when a session is first touched after a +// restart. The history read is SessionHistory's "latest" view — it starts at the most recent +// completed compaction — and only the accounting half is replayed. Two calibrations keep the +// replay faithful to the live path: +// +// - Calls the before hook rejected DID persist (as error parts) but were never accounted live, +// because a rejected call never reaches execute.after (tool.ts runs before hooks first). The +// plugin marks its own rejections (blockedMetadata) and declines/interrupts/input failures carry +// non-execution error types; both are skipped here. +// - Warnings the after hook injected into results persist with them, while live hashes before +// injecting; they are stripped so streaks rebuild exactly. +// +// Declared losses: C's failure streaks from before the latest compaction are invisible (miss +// direction; the streak self-heals within a few calls); B restarts with a rebuilt line store but +// a zero streak — safe because B's surgical block still requires the corroborating A streak, +// which IS rebuilt; Code Mode inner calls are invisible (the outer "execute" part is skipped, +// mirroring the live exemption, but the real tool names it re-entered with are not in history). +// The circuit breaker cannot be folded from history; the plugin persists it separately. + +// Error types a persisted error part can carry without the call ever having executed — the live +// after hook (and thus signal C) never saw them: declines and interruptions ("aborted" — +// runner step.ts declines, llm.ts settleStaleToolCalls) and input failures that never reached +// the tool ("tool.input-json" / "tool.input-incomplete" — runner INPUT_FAILURES). +const UNEXECUTED_ERROR_TYPES = new Set(["aborted", "tool.input-json", "tool.input-incomplete"]) + +export function fold(state: DoomLoopDetector.SessionState, messages: ReadonlyArray) { + for (const message of messages) { + if (message.type === "user") { + DoomLoopDetector.userMessage(state) + continue + } + // Standalone shell messages are user-run commands (Session.shell), not model tool calls; the + // model's background shell arrives as a "shell" tool part with metadata.status "running". + if (message.type !== "assistant") continue + for (const part of message.content) { + // The outer Code Mode tool re-enters with real tool names; its own call is exempt, as live. + if (part.type !== "tool" || part.name === "execute") continue + if (part.state.status === "error") { + if ( + DoomLoopDetector.hasBlockedMetadata(part.state.metadata) || + UNEXECUTED_ERROR_TYPES.has(part.state.error.type) + ) + continue + // The live C warning rides the persisted error message; strip it so the failure hash + // matches what live accounted. recordFailed excludes argument-validation rejections via + // the same message prefix the live path matches on. + const suffix = DoomLoopDetector.injectedFailureSuffix(part.name) + const message = part.state.error.message + const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message + DoomLoopDetector.recordFailed(state, { + tool: part.name, + errorText, + validation: errorText.startsWith("Invalid arguments for tool"), + }) + continue + } + if (part.state.status !== "completed") continue + const sigHash = DoomLoopDetector.signature(part.name, part.state.input) + if (part.name === "question" || (part.name === "shell" && part.state.metadata?.status === "running")) { + DoomLoopDetector.recordUnobserved(state, sigHash) + continue + } + // The persisted content carries any injected warning prepended by the live after hook; + // strip a leading warning part so the hash matches what live accounted. + const first = part.state.content[0] + const content = + first !== undefined && + first.type === "text" && + DoomLoopDetector.injectedWarnings(part.name).includes(first.text) + ? part.state.content.slice(1) + : part.state.content + DoomLoopDetector.recordCompleted(state, { + tool: part.name, + sigHash, + text: DoomLoopDetector.outputText(content), + }) + } + } + // Match the live steady state: an execution end tightens the store to the newest 1000 lines, + // and a fold from a long history could otherwise fill it to the 10k cap. + DoomLoopDetector.tighten(state) + // Fresh streak over the rebuilt line store is the self-consistent restart state; see header. + state.bStreak = 0 +} diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 8a01fb384def..5a33bd133c7f 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -104,6 +104,7 @@ import { WebSearchPlugins } from "./websearch/index.js" import { SkillPlugin } from "./skill.js" import { VcsHgPlugin } from "./vcs/hg.js" import { ToolInputRepairPlugin } from "./tool-input-repair.js" +import { DoomLoopPlugin } from "./doom-loop.js" import { OptimizePlugin } from "./optimize.js" import { VcsGitPlugin } from "./vcs/git.js" import { VerbosityPlugin } from "./verbosity.js" @@ -222,6 +223,7 @@ export type InternalPlugin = Plugin const pre = [ ToolInputRepairPlugin.Plugin, + DoomLoopPlugin.Plugin, ConfigWorktreePlugin.Plugin, ConfigMcpPlugin.Plugin, McpCodeModeDefaultsPlugin.Plugin, From feef0172897de9fdabf141962629a4a70d3c14b8 Mon Sep 17 00:00:00 2001 From: METAL-H Date: Fri, 30 Oct 2026 02:18:58 +0800 Subject: [PATCH 2/4] test(opencode): add doom loop detector and plugin regression tests --- .../test/plugin/doom-loop-detector.test.ts | 739 ++++++++++++ packages/core/test/plugin/doom-loop.test.ts | 1041 +++++++++++++++++ 2 files changed, 1780 insertions(+) create mode 100644 packages/core/test/plugin/doom-loop-detector.test.ts create mode 100644 packages/core/test/plugin/doom-loop.test.ts diff --git a/packages/core/test/plugin/doom-loop-detector.test.ts b/packages/core/test/plugin/doom-loop-detector.test.ts new file mode 100644 index 000000000000..33e500ea251b --- /dev/null +++ b/packages/core/test/plugin/doom-loop-detector.test.ts @@ -0,0 +1,739 @@ +import { describe, expect, it } from "bun:test" +import { DoomLoopDetector } from "@opencode/core/plugin/doom-loop/detector" + +type State = DoomLoopDetector.SessionState + +interface Emission { + readonly before: DoomLoopDetector.Verdict + readonly warning?: string +} + +// One emission replays the plugin's hook pair: the before verdict, then the matching +// after-accounting. Fixtures and expected timings come from the calibrated replays in +// tmp/loop-detection/scratch/ (review-replay*.ts), translated to assertions here. +function emit( + state: State, + tool: string, + input: unknown, + messageID: string, + out?: { readonly text?: string; readonly error?: string; readonly unobserved?: boolean }, +): Emission { + const sigHash = DoomLoopDetector.signature(tool, input) + const before = DoomLoopDetector.beforeCall(state, { tool, sigHash, messageID }) + let warning: string | undefined + if (out?.error !== undefined) { + warning = DoomLoopDetector.recordFailed(state, { + tool, + errorText: out.error, + validation: out.error.startsWith("Invalid arguments for tool"), + }) + } else if (out?.unobserved) { + DoomLoopDetector.recordUnobserved(state, sigHash) + } else { + warning = DoomLoopDetector.recordCompleted(state, { tool, sigHash, text: out?.text ?? "" }) + } + return { before, warning } +} + +describe("doom loop detector", () => { + describe("false-positive corpus", () => { + it("flags nothing for progressive edits on the same file (corpus §1.2)", () => { + const state = DoomLoopDetector.state() + const triggers: string[] = [] + for (let i = 1; i <= 8; i++) { + const r = emit( + state, + "edit", + { filePath: "a.ts", oldString: `fn${i}()`, newString: `/** doc */\nfn${i}()` }, + `msg_12_${i}`, + { text: "Edited a.ts" }, + ) + if (r.before.kind !== "pass") triggers.push(`before:${r.before.kind}`) + if (r.warning !== undefined) triggers.push(`warn:${r.warning.slice(0, 40)}`) + } + expect(triggers).toEqual([]) + // Every edit is a distinct signature with a one-line receipt: A streaks stay at 1, + // B skips short outputs, and no failure ever feeds C. + expect(state.signatures.size).toBe(8) + for (const entry of state.signatures.values()) expect(entry.streak).toBe(1) + expect(state.bStreak).toBe(0) + expect(state.failures.size).toBe(0) + }) + + it("flags nothing for six shell failures with distinct error texts (corpus §1.4)", () => { + const state = DoomLoopDetector.state() + const errors = [ + "Error: module 'left-pad' not found in lockfile", + "Error: Type 'string' is not assignable to 'number' at src/config.ts:12", + "Error: test suite 'auth' timed out after exhausting retries", + "Error: EACCES: permission denied, open '/etc/app/config.yml'", + "Error: command not found: tsc --project tsconfig.build.json", + "Error: assertion failed: expected 3 elements, rendered 2 in List", + ] + const triggers: string[] = [] + for (let i = 1; i <= 6; i++) { + const r = emit(state, "shell", { command: `npm run build --attempt=${i}` }, `msg_14_${i}`, { + error: errors[i - 1]!, + }) + if (r.before.kind !== "pass") triggers.push(`before:${r.before.kind}`) + if (r.warning !== undefined) triggers.push(`warn:${r.warning.slice(0, 40)}`) + } + expect(triggers).toEqual([]) + // Error events feed signal C only — they never enter A or B, so a run of failures + // with distinct messages cannot masquerade as a run of identical empty outputs. + expect(state.signatures.size).toBe(0) + expect(state.bStreak).toBe(0) + expect(state.lines.order).toEqual([]) + // C saw six different failure identities: the streak never climbs past 1. + expect(state.failures.get("shell")?.streak).toBe(1) + }) + + it("asks rather than hard-refuses for user-requested polling (corpus §2.1)", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "check-ci" }) + const pollOut = "job fortytwo: status RUNNING\nprogress rows done\neta three minutes" + const asks: Array<{ call: number; signal: string }> = [] + const warns: number[] = [] + for (let i = 1; i <= 8; i++) { + const before = DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `poll_${i}` }) + if (before.kind === "assert") asks.push({ call: i, signal: before.signal }) + if (before.kind === "circuit") asks.push({ call: i, signal: "HARD-CIRCUIT" }) + const warning = DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: pollOut }) + if (warning !== undefined) warns.push(i) + } + // The block tier is a permission ask (an escape hatch the user can allow), never a + // hard refusal: A's tier asks at the 7th emission, after six identical accountings. + expect(asks).toEqual([ + { call: 7, signal: "a" }, + { call: 8, signal: "a" }, + ]) + // The warning tier fired already at the 3rd accounting. + expect(warns[0]).toBe(3) + expect(state.bStreak).toBeGreaterThanOrEqual(3) + }) + }) + + describe("signal A", () => { + it("warns on the 3rd identical output and asks on the 7th emission", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("grep", { pattern: "TODO", path: "src" }) + const output = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + const warns: number[] = [] + const asks: number[] = [] + for (let i = 1; i <= 8; i++) { + const before = DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: `a_${i}` }) + if (before.kind === "assert") asks.push(i) + expect(before.kind === "assert" ? before.signal : before.kind).not.toBe("circuit") + const warning = DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: output }) + if (warning !== undefined) warns.push(i) + } + // A's own warning lands at the 3rd accounting; B's global warnings follow at the 3rd + // and 5th zero-gain accountings (calls 4 and 6). + expect(warns).toEqual([3, 4, 6]) + // Block threshold: the call emitted after the 6th accounting (the 7th emission) is + // challenged in before. + expect(asks).toEqual([7, 8]) + }) + + it("resets the streak to 1 when the output changes", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "git status" }) + const out1 = "on branch main\nnothing to commit\nworking tree clean" + const out2 = "on branch dev\nnothing to commit\nworking tree clean" + for (let i = 1; i <= 3; i++) { + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `s_${i}` }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out1 }) + } + expect(state.signatures.get(sig)?.streak).toBe(3) + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: "s_4" }) + const warning = DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out2 }) + expect(state.signatures.get(sig)?.streak).toBe(1) + // The changed output still repeats two known lines, so B (not A) is what warns here. + expect(warning).toContain("no new information") + }) + + it("tracks the S/D/T rotation per signature with B blocking first", () => { + const state = DoomLoopDetector.state() + const sigs = ["S", "D", "T"].map((c) => DoomLoopDetector.signature("shell", { c })) + const outs = ["outS\nmoreS\nextraS", "outD\nmoreD\nextraD", "outT\nmoreT\nextraT"] + let firstBWarn = 0 + let aWarnAt = 0 + let firstBlock = 0 + let firstBlockSignal = "" + for (let i = 1; i <= 21; i++) { + const k = (i - 1) % 3 + const before = DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sigs[k]!, messageID: `r_${i}` }) + if (before.kind === "assert" && firstBlock === 0) { + firstBlock = i + firstBlockSignal = before.signal + } + const warning = DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sigs[k]!, text: outs[k]! }) + if (warning !== undefined && warning.includes("no new information") && firstBWarn === 0) firstBWarn = i + if (warning !== undefined && warning.includes("byte-identical") && aWarnAt === 0) aWarnAt = i + } + // B is a global counter, so it warns first at the 3rd zero-gain call; A's per-signature + // warning lands on S's 3rd occurrence; the first block is B's surgical tier at call 12 + // (bStreak >= 8 with this signature's A streak >= 3), ahead of A's own 6th-identical tier. + expect(firstBWarn).toBe(6) + expect(aWarnAt).toBe(7) + expect(firstBlock).toBe(12) + expect(firstBlockSignal).toBe("b") + // Rotation does not reset per-signature counters: every signature reached 7 occurrences. + for (const sig of sigs) expect(state.signatures.get(sig)?.streak).toBe(7) + }) + + it("hashes before any warning append, so appended text never feeds back", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("grep", { pattern: "TODO" }) + const out = "a.ts:1:TODO\nb.ts:2:TODO\nc.ts:3:TODO" + for (let i = 1; i <= 3; i++) { + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: `w_${i}` }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: out }) + } + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: "w_4" }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: out }) + // If the appended warning were hashed, the streak would restart at 1 instead of 4. + expect(state.signatures.get(sig)?.streak).toBe(4) + }) + }) + + describe("signal B", () => { + it("warns at the 3rd and 5th zero-gain accounting", () => { + const state = DoomLoopDetector.state() + // Seed the line store so the probed signature's outputs are all zero-gain. + DoomLoopDetector.recordCompleted(state, { + tool: "read", + sigHash: DoomLoopDetector.signature("read", { f: "seed" }), + text: "l1\nl2\nl3\nl4\nl5", + }) + const sig = DoomLoopDetector.signature("shell", { command: "git status" }) + const warns: number[] = [] + for (let i = 1; i <= 5; i++) { + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `b_${i}` }) + const warning = DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: "l1\nl2\nl3" }) + if (warning !== undefined) warns.push(i) + } + expect(warns).toEqual([3, 5]) + }) + + it("surgically blocks the 9th zero-gain emission when A streak >= 3 corroborates", () => { + const state = DoomLoopDetector.state() + DoomLoopDetector.recordCompleted(state, { + tool: "read", + sigHash: DoomLoopDetector.signature("read", { f: "seed" }), + text: "alpha\nbravo\ncharlie\ndelta\necho", + }) + const sigX = DoomLoopDetector.signature("shell", { command: "git status" }) + const out = "alpha\nbravo\ncharlie" + const other = (i: number) => DoomLoopDetector.signature("shell", { command: `variant ${i}` }) + const results: string[] = [] + let emissions = 0 + const emitSig = (sig: string) => { + emissions++ + return DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `x_${emissions}` }) + } + const complete = (sig: string) => + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out }) + // X, O1, X, O2, X, O3, X, O4, X — nine zero-gain emissions; X's A streak stays below + // its own block tier (6) so B's slot is reached. + for (let i = 1; i <= 4; i++) { + const vx = emitSig(sigX) + results.push(vx.kind === "assert" ? `X${i}:${vx.signal}` : `X${i}:${vx.kind}`) + if (vx.kind === "pass") complete(sigX) + const vo = emitSig(other(i)) + results.push(vo.kind === "assert" ? `O${i}:${vo.signal}` : `O${i}:${vo.kind}`) + if (vo.kind === "pass") complete(other(i)) + } + const v5 = emitSig(sigX) + results.push(v5.kind === "assert" ? `X5:${v5.signal}` : `X5:${v5.kind}`) + expect(results).toEqual([ + "X1:pass", + "O1:pass", + "X2:pass", + "O2:pass", + "X3:pass", + "O3:pass", + "X4:pass", + "O4:pass", + "X5:b", + ]) + expect(state.bStreak).toBe(8) + expect(state.signatures.get(sigX)?.streak).toBe(4) + }) + + it("never blocks alone without A corroboration", () => { + const low = DoomLoopDetector.state() + low.bStreak = 8 + const sigLow = DoomLoopDetector.signature("grep", { pattern: "fresh" }) + low.signatures.set(sigLow, { lastOutputHash: "h", streak: 2 }) + const vLow = DoomLoopDetector.beforeCall(low, { tool: "grep", sigHash: sigLow, messageID: "m" }) + expect(vLow.kind).toBe("pass") + + const high = DoomLoopDetector.state() + high.bStreak = 8 + const sigHigh = DoomLoopDetector.signature("grep", { pattern: "fresh" }) + high.signatures.set(sigHigh, { lastOutputHash: "h", streak: 3 }) + const vHigh = DoomLoopDetector.beforeCall(high, { tool: "grep", sigHash: sigHigh, messageID: "m" }) + expect(vHigh).toMatchObject({ kind: "assert", signal: "b" }) + }) + + it("clears the streak on productive calls (novel >= 2 or ratio >= 50%)", () => { + const state = DoomLoopDetector.state() + const sig1 = DoomLoopDetector.signature("shell", { c: 1 }) + const sig2 = DoomLoopDetector.signature("shell", { c: 2 }) + const sig3 = DoomLoopDetector.signature("shell", { c: 3 }) + const sig4 = DoomLoopDetector.signature("shell", { c: 4 }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig1, text: "n1\nn2\nn3" }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig1, text: "n1\nn2\nn3" }) + expect(state.bStreak).toBe(1) + // Two novel lines out of three clear the streak. + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig2, text: "new1\nnew2\nn1" }) + expect(state.bStreak).toBe(0) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig2, text: "new1\nnew2\nn1" }) + expect(state.bStreak).toBe(1) + // Two novel lines out of four (50%) also count as productive. + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig3, text: "new3\nnew4\nnew1\nnew2" }) + expect(state.bStreak).toBe(0) + // A single novel line in a four-line output (25%) is a decorator variant, not progress. + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig4, text: "new5\nnew1\nnew2\nnew3" }) + expect(state.bStreak).toBe(1) + }) + + it("exempts outputs shorter than three lines from B while A still counts them", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("edit", { filePath: "a.ts" }) + const r1 = emit(state, "edit", { filePath: "a.ts" }, "short_1", { text: "Edited a.ts" }) + const r2 = emit(state, "edit", { filePath: "a.ts" }, "short_2", { text: "Edited a.ts" }) + const r3 = emit(state, "edit", { filePath: "a.ts" }, "short_3", { text: "Edited a.ts" }) + expect(r1.warning).toBeUndefined() + expect(r2.warning).toBeUndefined() + // A has no length exemption: the third identical one-line receipt warns. + expect(r3.warning).toContain("byte-identical") + expect(state.bStreak).toBe(0) + expect(state.signatures.get(sig)?.streak).toBe(3) + }) + + it("normalizes volatile tokens before novelty counting", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "build" }) + emit(state, "shell", { command: "build" }, "v_1", { text: "build ok in 0.03s\nline two here\nline three here" }) + const r = emit(state, "shell", { command: "build" }, "v_2", { + text: "build ok in 0.05s\nline two here\nline three here", + }) + // "in 0.03s" and "in 0.05s" are the same normalized line, so the second call adds nothing. + expect(state.bStreak).toBe(1) + expect(r.warning).toBeUndefined() + // The raw text differs, so A's byte-identical streak does not accumulate either. + expect(state.signatures.get(sig)?.streak).toBe(1) + }) + + it("counts novel lines per distinct line within one output", () => { + const seeded = DoomLoopDetector.state() + DoomLoopDetector.recordCompleted(seeded, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { seed: 1 }), + text: "seenA\nseenB\nseenC\nseenD", + }) + // One distinct new line repeated three times inside a four-line output is one new fact, + // not three: ratio 25% is unproductive. + DoomLoopDetector.recordCompleted(seeded, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { v: 1 }), + text: "freshX\nfreshX\nfreshX\nseenB", + }) + expect(seeded.bStreak).toBe(1) + + const control = DoomLoopDetector.state() + DoomLoopDetector.recordCompleted(control, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { seed: 1 }), + text: "seenA\nseenB\nseenC\nseenD", + }) + DoomLoopDetector.recordCompleted(control, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { v: 1 }), + text: "freshY\nseenB\nseenC\nseenD", + }) + expect(control.bStreak).toBe(1) + }) + + it("caps stored lines at 1KB", () => { + const state = DoomLoopDetector.state() + const pad = (i: number) => `pad${i}a\npad${i}b` + emit(state, "shell", { c: 1 }, "cap_1", { text: `${"x".repeat(2000)}\n${pad(1)}` }) + emit(state, "shell", { c: 1 }, "cap_2", { text: `${"x".repeat(1500)}\n${pad(1)}` }) + const r3 = emit(state, "shell", { c: 1 }, "cap_3", { text: `${"x".repeat(1024)}DIFFERENT\n${pad(1)}` }) + // Every long line truncates to the same 1024 characters, so even the call whose + // difference lives past the cap is judged unproductive. + expect(state.bStreak).toBe(2) + expect(r3.warning).toBeUndefined() + }) + + it("bounds the line store at 10k lines with FIFO half-eviction", () => { + const state = DoomLoopDetector.state() + const lines = (i: number) => `line${i}a\nline${i}b\nline${i}c` + for (let i = 0; i < 3333; i++) { + DoomLoopDetector.recordCompleted(state, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { i }), + text: lines(i), + }) + } + expect(state.lines.order).toHaveLength(9999) + DoomLoopDetector.recordCompleted(state, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { i: 3333 }), + text: lines(3333), + }) + // Crossing the cap evicts the oldest half in one sweep. + expect(state.lines.order).toHaveLength(5002) + expect(state.lines.set.has("line0a")).toBe(false) + expect(state.lines.set.has("line3333a")).toBe(true) + }) + + it("tighten keeps the newest 1000 lines and preserves A and C", () => { + const state = DoomLoopDetector.state() + for (let i = 0; i < 667; i++) { + DoomLoopDetector.recordCompleted(state, { + tool: "shell", + sigHash: DoomLoopDetector.signature("shell", { i }), + text: `tight${i}a\ntight${i}b\ntight${i}c`, + }) + } + expect(state.lines.order).toHaveLength(2001) + state.signatures.set("sig-a", { lastOutputHash: "h", streak: 5 }) + state.failures.set("edit", { lastFailureHash: "h", streak: 4 }) + DoomLoopDetector.tighten(state) + expect(state.lines.order).toHaveLength(1000) + expect(state.lines.set.has("tight0a")).toBe(false) + expect(state.lines.set.has("tight666c")).toBe(true) + // The execution-end sweep must not delete A/C counters. + expect(state.signatures.get("sig-a")).toEqual({ lastOutputHash: "h", streak: 5 }) + expect(state.failures.get("edit")).toEqual({ lastFailureHash: "h", streak: 4 }) + }) + }) + + describe("signal C", () => { + it("warns at the 4th equivalent failure and asks on the 7th failure emission", () => { + const state = DoomLoopDetector.state() + let warnAt = 0 + let blockedAt = 0 + let blockedSignal = "" + for (let i = 1; i <= 8; i++) { + const before = DoomLoopDetector.beforeCall(state, { + tool: "edit", + sigHash: DoomLoopDetector.signature("edit", { i }), + messageID: `c_${i}`, + }) + if (before.kind === "assert" && blockedAt === 0) { + blockedAt = i + blockedSignal = before.signal + } + const warning = DoomLoopDetector.recordFailed(state, { + tool: "edit", + errorText: "String not found in document", + validation: false, + }) + if (warning !== undefined) warnAt = i + } + expect(warnAt).toBe(4) + // The 7th failure emission is challenged in before, after six accountings. + expect(blockedAt).toBe(7) + expect(blockedSignal).toBe("c") + }) + + it("treats volatile-token differences as equivalent failures", () => { + const state = DoomLoopDetector.state() + for (let i = 1; i <= 5; i++) { + DoomLoopDetector.recordFailed(state, { + tool: "shell", + errorText: `Request failed after ${i}.${i}s: timeout`, + validation: false, + }) + } + expect(state.failures.get("shell")?.streak).toBe(5) + }) + + it("survives interleaved successes of other tools and clears on same-tool success", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("edit", { filePath: "a.ts", oldString: "x" }) + for (let i = 0; i < 3; i++) { + DoomLoopDetector.beforeCall(state, { tool: "edit", sigHash: sig, messageID: `rc_${i}` }) + DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "String not found", validation: false }) + DoomLoopDetector.recordCompleted(state, { + tool: "read", + sigHash: DoomLoopDetector.signature("read", { f: `f${i}` }), + text: `new ${i}\nlines ${i}\nhere ${i}`, + }) + } + // retry-then-read: successful reads of other files must not launder the failing edit. + expect(state.failures.get("edit")?.streak).toBe(3) + DoomLoopDetector.recordCompleted(state, { tool: "edit", sigHash: sig, text: "ok" }) + expect(state.failures.has("edit")).toBe(false) + }) + + it("never counts argument-validation failures", () => { + const state = DoomLoopDetector.state() + for (let i = 0; i < 8; i++) { + DoomLoopDetector.recordFailed(state, { + tool: "edit", + errorText: 'Invalid arguments for tool "edit":\nmissing oldString', + validation: true, + }) + } + expect(state.failures.size).toBe(0) + }) + }) + + describe("lifecycle", () => { + it("compaction zeroes A streaks but keeps hashes, clears B, and keeps C", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "git status" }) + const out = "on branch main\nnothing to commit\nworking tree clean" + for (let i = 0; i < 4; i++) { + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `co_${i}` }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out }) + } + for (let i = 0; i < 4; i++) { + DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "String not found", validation: false }) + } + const hashBefore = state.signatures.get(sig)?.lastOutputHash + DoomLoopDetector.compacted(state) + expect(state.signatures.get(sig)?.streak).toBe(0) + expect(state.signatures.get(sig)?.lastOutputHash).toBe(hashBefore) + expect(state.lines.order).toEqual([]) + expect(state.bStreak).toBe(0) + // C intentionally survives compaction: failure equivalence does not depend on context. + expect(state.failures.get("edit")?.streak).toBe(4) + // The kept hash makes the first post-compaction repeat a soft boundary (streak 1, not 2). + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: "co_5" }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out }) + expect(state.signatures.get(sig)?.streak).toBe(1) + }) + + it("user messages clear only bStreak and the circuit breaker", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("grep", { pattern: "TODO" }) + for (let i = 0; i < 5; i++) { + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: `u_${i}` }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "a:1\nb:2\nc:3" }) + } + DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "boom", validation: false }) + DoomLoopDetector.userMessage(state) + // A/C survive a user interjection — clearing them would hand the loop a fresh counter. + expect(state.signatures.get(sig)?.streak).toBe(5) + expect(state.failures.has("edit")).toBe(true) + expect(state.bStreak).toBe(0) + }) + + it("file changes clear bStreak but never touch A or C", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "git status" }) + for (let i = 0; i < 3; i++) { + DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `f_${i}` }) + DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: "l1\nl2\nl3" }) + } + DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "boom", validation: false }) + expect(state.bStreak).toBe(2) + DoomLoopDetector.filesChanged(state) + expect(state.bStreak).toBe(0) + expect(state.signatures.get(sig)?.streak).toBe(3) + expect(state.failures.get("edit")?.streak).toBe(1) + }) + }) + + describe("circuit breaker", () => { + it("hard-stops without asking and stays sticky until the user speaks", () => { + const state = DoomLoopDetector.state() + state.bStreak = 12 + const sig = DoomLoopDetector.signature("read", { f: "a" }) + const v1 = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "ci_1" }) + const v2 = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "ci_2" }) + expect(v1.kind).toBe("circuit") + expect(v2.kind).toBe("circuit") + expect(v1.kind === "circuit" && v1.message).toContain("Stop making tool calls") + DoomLoopDetector.userMessage(state) + const v3 = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "ci_3" }) + expect(v3.kind).toBe("pass") + }) + + it("short-circuits before the repeat guard so nothing asks during circuit", () => { + const state = DoomLoopDetector.state() + state.bStreak = 12 + const sig = DoomLoopDetector.signature("read", { f: "x" }) + for (let i = 1; i <= 6; i++) { + const v = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "circ" }) + expect(v.kind).toBe("circuit") + } + }) + }) + + describe("same-message repeat guard", () => { + it("asks exactly once at the 3rd emission of one message and stays silent after", () => { + const state = DoomLoopDetector.state() + const ask = (messageID: string) => + DoomLoopDetector.beforeCall(state, { + tool: "read", + sigHash: DoomLoopDetector.signature("read", { filePath: "x" }), + messageID, + }) + expect(ask("msg_1").kind).toBe("pass") + expect(ask("msg_1").kind).toBe("pass") + expect(ask("msg_2").kind).toBe("pass") + expect(ask("msg_2").kind).toBe("pass") + const third = ask("msg_2") + expect(third).toMatchObject({ kind: "assert", signal: "guard" }) + if (third.kind === "assert") expect(third.message).toContain("emitted 3 times") + // One-shot: the 4th and 5th emissions of the same message do not ask again. + expect(ask("msg_2").kind).toBe("pass") + expect(ask("msg_2").kind).toBe("pass") + }) + + it("drops the old bucket when the messageID changes", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("read", { filePath: "x" }) + const emit = (messageID: string) => DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID }) + expect(emit("m_a").kind).toBe("pass") + expect(emit("m_a").kind).toBe("pass") + // Two emissions in a new message drop the m_a bucket; returning to m_a starts from 1, + // so its first emission back does not ask (a retained count of 2 would ask). + expect(emit("m_b").kind).toBe("pass") + expect(emit("m_a").kind).toBe("pass") + expect(emit("m_a").kind).toBe("pass") + // And the fresh m_a bucket still asks exactly once at its own 3rd emission. + expect(emit("m_a")).toMatchObject({ kind: "assert", signal: "guard" }) + // Switching again drops the m_a bucket, so m_b recounts from 1 as well. + expect(emit("m_b").kind).toBe("pass") + expect(emit("m_b").kind).toBe("pass") + expect(emit("m_b")).toMatchObject({ kind: "assert", signal: "guard" }) + }) + + it("merges the A streak into the guard copy when carried across messages", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("grep", { pattern: "TODO" }) + for (let i = 0; i < 3; i++) { + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: "old_msg" }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "a:1\nb:2\nc:3" }) + } + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: "new_msg" }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "a:1\nb:2\nc:3" }) + DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: "new_msg" }) + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "a:1\nb:2\nc:3" }) + const v = DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: "new_msg" }) + expect(v).toMatchObject({ kind: "assert", signal: "guard" }) + if (v.kind === "assert") expect(v.message).toContain("identical output") + }) + }) + + describe("unobserved layer", () => { + it("prompts exactly once, at the 7th emission", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("question", {}) + let askedAt = 0 + for (let i = 1; i <= 12; i++) { + const before = DoomLoopDetector.beforeCall(state, { tool: "question", sigHash: sig, messageID: `q_${i}` }) + if (before.kind === "assert" && askedAt === 0) askedAt = i + if (before.kind === "pass") DoomLoopDetector.recordUnobserved(state, sig) + } + expect(askedAt).toBe(7) + expect(state.highTierPrompted).toBe(true) + }) + + it("asks once for the guard and once for the unobserved tier, never more (combination)", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "nohup build &" }) + let guard = 0 + let unobserved = 0 + for (let i = 1; i <= 8; i++) { + const before = DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: "bg" }) + if (before.kind === "assert" && before.signal === "guard") guard++ + if (before.kind === "assert" && before.signal === "unobserved") unobserved++ + if (before.kind === "pass") DoomLoopDetector.recordUnobserved(state, sig) + } + // The guard asks once at the 3rd emission; the high-tier prompt fires once at the 7th. + expect(guard).toBe(1) + expect(unobserved).toBe(1) + }) + }) + + describe("hashing and resource strings", () => { + it("signatures are wildcard-safe 16-char hex of canonical JSON", () => { + const a = DoomLoopDetector.signature("shell", { command: "ls", flag: true }) + const b = DoomLoopDetector.signature("shell", { flag: true, command: "ls" }) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{16}$/) + expect(DoomLoopDetector.signature("shell", { command: "ls -la" })).not.toBe(a) + }) + + it("isolates per-signal resource suffixes", () => { + expect(DoomLoopDetector.resourceSuffix).toEqual({ + guard: ":repeat", + unobserved: ":unobserved", + a: "", + b: "", + c: "", + }) + }) + + it("falls back to stringified output so output-only tools still hash meaningfully", () => { + const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("mcp__res", { uri: "x" }) + const text1 = DoomLoopDetector.outputText([], { bytes: 10 }) + const text2 = DoomLoopDetector.outputText([], { bytes: 10 }) + DoomLoopDetector.recordCompleted(state, { tool: "mcp__res", sigHash: sig, text: text1 }) + DoomLoopDetector.recordCompleted(state, { tool: "mcp__res", sigHash: sig, text: text2 }) + expect(state.signatures.get(sig)?.streak).toBe(2) + expect( + DoomLoopDetector.outputText([ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ]), + ).toBe("a\nb") + expect(DoomLoopDetector.outputText("plain")).toBe("plain") + }) + }) + + describe("capacity bounds", () => { + const record = (state: State, sig: string) => + DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "same output" }) + + it("evicts the oldest signature past the cap and restarts a recurred one from streak 1", () => { + const state = DoomLoopDetector.state() + record(state, "s_old") + record(state, "s_old") + expect(state.signatures.get("s_old")?.streak).toBe(2) + for (let i = 0; i < 4096; i++) record(state, `s_${i}`) + expect(state.signatures.size).toBe(4096) + expect(state.signatures.has("s_old")).toBe(false) + // A recurred evicted signature restarts at streak 1 — the same as an output change. + record(state, "s_old") + expect(state.signatures.get("s_old")?.streak).toBe(1) + }) + + it("keeps re-firing signatures hot: eviction takes the coldest entry", () => { + const state = DoomLoopDetector.state() + record(state, "hot_a") + record(state, "hot_b") + for (let i = 0; i < 4094; i++) record(state, `cold_${i}`) + expect(state.signatures.size).toBe(4096) + // Re-firing moves both to the newest positions, so the next insert evicts cold_0. + record(state, "hot_a") + record(state, "hot_b") + record(state, "cold_new") + expect(state.signatures.size).toBe(4096) + expect(state.signatures.get("hot_a")?.streak).toBe(2) + expect(state.signatures.get("hot_b")?.streak).toBe(2) + expect(state.signatures.has("cold_0")).toBe(false) + }) + + it("bounds the unobserved map with the same LRU rule", () => { + const state = DoomLoopDetector.state() + DoomLoopDetector.recordUnobserved(state, "u_old") + for (let i = 0; i < 4096; i++) DoomLoopDetector.recordUnobserved(state, `u_${i}`) + expect(state.unobserved.size).toBe(4096) + expect(state.unobserved.has("u_old")).toBe(false) + DoomLoopDetector.recordUnobserved(state, "u_0") + expect(state.unobserved.get("u_0")).toBe(2) + }) + }) +}) diff --git a/packages/core/test/plugin/doom-loop.test.ts b/packages/core/test/plugin/doom-loop.test.ts new file mode 100644 index 000000000000..7887a7f31242 --- /dev/null +++ b/packages/core/test/plugin/doom-loop.test.ts @@ -0,0 +1,1041 @@ +import { describe, expect } from "bun:test" +import { Agent } from "@opencode/core/agent" +import { Model } from "@opencode/core/model" +import { DoomLoopPlugin } from "@opencode/core/plugin/doom-loop" +import { DoomLoopDetector } from "@opencode/core/plugin/doom-loop/detector" +import { DoomLoopFold } from "@opencode/core/plugin/doom-loop/fold" +import { Permission } from "@opencode/core/permission" +import { Provider } from "@opencode/core/provider" +import { RelativePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { SessionEvent } from "@opencode/core/session/event" +import { SessionInbox } from "@opencode/core/session/inbox" +import { SessionMessage } from "@opencode/core/session/message" +import { toSessionError } from "@opencode/core/session/to-session-error" +import type { AssertInput } from "@opencode/plugin/permission" +import type { ToolHooks } from "@opencode/plugin/effect/tool" +import { Event } from "@opencode/schema/event" +import { Money } from "@opencode/schema/money" +import { Shell } from "@opencode/schema/shell" +import { Tool } from "@opencode/schema/tool" +import { Cause, DateTime, Effect, Exit, Option, Queue, Stream } from "effect" +import type { Schema } from "effect" +import { it } from "../lib/effect" +import { host } from "./host" + +const sessionID = Session.ID.make("ses_doom_loop") +const agent = Agent.ID.make("build") + +type BeforeHook = (input: ToolHooks["execute.before"]) => Effect.Effect +type AfterHook = (input: ToolHooks["execute.after"]) => Effect.Effect +type Outcome = (input: AssertInput) => Effect.Effect + +type Published = + | SessionEvent.Deleted + | SessionEvent.InboxEnqueued + | SessionEvent.Step.Ended + | SessionEvent.Compaction.Ended + | SessionEvent.Execution.Succeeded + | SessionEvent.Execution.Failed + | SessionEvent.Execution.Interrupted + +interface Harness { + readonly before: BeforeHook + readonly after: AfterHook + readonly asserted: AssertInput[] + readonly interrupts: () => number + readonly setOutcome: (outcome: Outcome) => void + readonly publish: (event: Published) => Effect.Effect +} + +interface HarnessOptions { + /** Durable history a restarted process would fold on first touch. */ + readonly history?: ReadonlyArray + /** Per-session histories, for multi-session scenarios; falls back to `history`. */ + readonly histories?: Readonly>> + /** Simulate a history read failure: the plugin must fall back to a fresh state. */ + readonly contextFails?: boolean + /** Durable plugin KV; share one map across harness instances to simulate a restart. */ + readonly storage?: Map +} + +/** Boots the plugin against stubbed domains and returns its registered hooks plus spies. */ +const run = Effect.fnUntraced(function* (options?: HarnessOptions) { + DoomLoopPlugin.setMaxTrackedSessions() + const base = host() + const kv = options?.storage ?? new Map() + let beforeHook: BeforeHook | undefined + let afterHook: AfterHook | undefined + const asserted: AssertInput[] = [] + const outcome: { current: Outcome } = { current: () => Effect.void } + let interrupts = 0 + const queue = yield* Queue.unbounded() + yield* DoomLoopPlugin.Plugin.effect( + host({ + permission: { + ...base.permission, + assert: (input) => { + asserted.push(input) + return outcome.current(input) + }, + }, + tool: { + ...base.tool, + hook: (name, callback) => { + if (name === "execute.before") { + // Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + beforeHook = callback as unknown as BeforeHook + } + if (name === "execute.after") { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + afterHook = callback as unknown as AfterHook + } + return Effect.succeed({ dispose: Effect.void }) + }, + }, + event: { + subscribe: () => Stream.fromQueue(queue), + }, + storage: { + get: (key) => Effect.succeed(kv.get(key)), + set: (key, value) => Effect.sync(() => void kv.set(key, value)), + remove: (key) => Effect.sync(() => void kv.delete(key)), + scan: () => Effect.die("unused storage.scan"), + }, + session: { + interrupt: () => + Effect.sync(() => { + interrupts++ + return { interrupted: true } + }), + context: (input) => + options?.contextFails === true + ? Effect.die(new Error("history read failed")) + : Effect.succeed(options?.histories?.[input.sessionID] ?? options?.history ?? []), + }, + }), + ) + if (!beforeHook || !afterHook) return yield* Effect.die("doom loop plugin did not register tool hooks") + const publish = (event: Published) => + Effect.gen(function* () { + yield* Queue.offer(queue, event) + // Wait until the subscription fiber has applied the event before assertions run. + yield* drained(queue) + }) + return { + before: beforeHook, + after: afterHook, + asserted, + interrupts: () => interrupts, + setOutcome: (next: Outcome) => { + outcome.current = next + }, + publish, + } satisfies Harness +}) + +const beforeEvent = ( + tool: string, + input: unknown, + messageID: string, + asSession: Session.ID = sessionID, +): ToolHooks["execute.before"] => ({ + tool, + input, + sessionID: asSession, + agent, + messageID: SessionMessage.ID.make(`msg_${messageID}`), + id: Tool.CallID.make(`call_${messageID}`), +}) + +const completedEvent = ( + tool: string, + input: unknown, + messageID: string, + result: Tool.Result, + asSession: Session.ID = sessionID, +): ToolHooks["execute.after"] => ({ + ...beforeEvent(tool, input, messageID, asSession), + status: "completed", + result, +}) + +type ToolErrorEvent = Extract + +const errorEvent = (tool: string, input: unknown, messageID: string, error: Tool.Error): ToolErrorEvent => ({ + ...beforeEvent(tool, input, messageID), + status: "error", + error, +}) + +const durable = { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) } + +const inboxEnqueued = (item: SessionInbox.Item): SessionEvent.InboxEnqueued => ({ + id: Event.ID.create(), + created: 0, + durable, + type: "session.inbox.enqueued", + data: { inboxID: SessionMessage.ID.make("msg_inb_doom"), sessionID, item }, +}) + +const stepEnded = (files?: string[]): SessionEvent.Step.Ended => ({ + id: Event.ID.create(), + created: 0, + durable, + type: "session.step.ended", + data: { + sessionID, + assistantMessageID: SessionMessage.ID.make("msg_doom_step"), + finish: "stop", + cost: Money.USD.make(0), + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + ...(files === undefined ? {} : { files: files.map((file) => RelativePath.make(file)) }), + }, +}) + +const compactionEnded: SessionEvent.Compaction.Ended = { + id: Event.ID.create(), + created: 0, + durable, + type: "session.compaction.ended", + data: { sessionID, reason: "auto", text: "compacted" }, +} + +const executionSucceeded = (id: Session.ID): SessionEvent.Execution.Succeeded => ({ + id: Event.ID.create(), + created: 0, + durable: { aggregateID: id, seq: Event.Seq.make(0), version: Event.Version.make(1) }, + type: "session.execution.succeeded", + data: { sessionID: id }, +}) + +const sessionDeleted = (id: Session.ID): SessionEvent.Deleted => ({ + id: Event.ID.create(), + created: 0, + durable: { aggregateID: id, seq: Event.Seq.make(0), version: Event.Version.make(1) }, + type: "session.deleted", + data: { sessionID: id }, +}) + +// History fixtures: what SessionHistory.load("latest") would hand a restarted process. +const foldedAt = DateTime.makeUnsafe(0) +const foldedModel = { id: Model.ID.make("model"), providerID: Provider.ID.make("provider") } + +const foldedAssistant = (index: string, part: SessionMessage.AssistantTool): SessionMessage.Info => + SessionMessage.Assistant.make({ + id: SessionMessage.ID.make(`msg_fold_${index}`), + type: "assistant", + agent, + model: foldedModel, + content: [part], + time: { created: foldedAt }, + }) + +const foldedCall = ( + index: string, + tool: string, + input: Record, + output: string, + warning?: string, +): SessionMessage.Info => + foldedAssistant( + index, + SessionMessage.AssistantTool.make({ + type: "tool", + id: `call_fold_${index}`, + name: tool, + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input, + // A warning injected by the live after hook persists as a leading text part. + content: + warning === undefined + ? [{ type: "text" as const, text: output }] + : [ + { type: "text" as const, text: warning }, + { type: "text" as const, text: output }, + ], + }), + time: { created: foldedAt }, + }), + ) + +const foldedFailure = ( + index: string, + tool: string, + input: Record, + message: string, + options?: { readonly type?: string; readonly metadata?: Record }, +): SessionMessage.Info => + foldedAssistant( + index, + SessionMessage.AssistantTool.make({ + type: "tool", + id: `call_fold_${index}`, + name: tool, + state: SessionMessage.ToolStateError.make({ + status: "error", + input, + // Real execution failures persist as "tool.execution" (to-session-error.ts). + error: { type: options?.type ?? "tool.execution", message }, + ...(options?.metadata === undefined ? {} : { metadata: options.metadata }), + }), + time: { created: foldedAt }, + }), + ) + +// A user-run shell command (Session.shell), which the fold must NOT count as a model tool call. +const foldedUserShell = (index: string, command: string): SessionMessage.Info => + SessionMessage.Shell.make({ + id: SessionMessage.ID.make(`msg_fold_shell_${index}`), + type: "shell", + shellID: Shell.ID.make(`sh_fold_${index}`), + command, + status: "exited", + exit: 0, + time: { created: foldedAt }, + }) + +// The model's background shell: a completed "shell" tool part whose receipt metadata says running. +const foldedBackgroundShell = (index: string, input: Record): SessionMessage.Info => + foldedAssistant( + index, + SessionMessage.AssistantTool.make({ + type: "tool", + id: `call_fold_${index}`, + name: "shell", + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input, + content: [{ type: "text", text: "running" }], + metadata: { status: "running" }, + }), + time: { created: foldedAt }, + }), + ) + +// Volatile-token normalization collapses digits to placeholders, so distinct fixture lines +// encode their index in letters. +const LETTERS = "abcdefghijklmnopqrstuvwxyz" +const lineTag = (i: number) => + `line-${LETTERS[Math.floor(i / 676) % 26]}${LETTERS[Math.floor(i / 26) % 26]}${LETTERS[i % 26]}` + +const text = (value: string): Tool.Result => ({ content: [{ type: "text", text: value }] }) + +const findToolError = (exit: Exit.Exit) => Option.getOrUndefined(Exit.findErrorOption(exit)) + +const appendedText = (event: ToolHooks["execute.after"]): string => { + if (event.status !== "completed" || !Array.isArray(event.result.content)) return "" + return event.result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n") +} + +const settle = (predicate: () => boolean, remaining = 1000): Effect.Effect => + Effect.gen(function* () { + if (predicate()) return + if (remaining === 0) return yield* Effect.fail(new Error("timed out waiting for async plugin work")) + yield* Effect.promise(() => Bun.sleep(1)) + yield* settle(predicate, remaining - 1) + }) + +// Polls until the plugin's forked subscription fiber takes the published event: the take → +// filter → runForEach chain runs synchronously per element, so a drained queue means the event +// has been applied. Bounded, and fails on timeout instead of trusting a fixed number of yields. +const drained = (queue: Queue.Queue, remaining = 1000): Effect.Effect => + Effect.gen(function* () { + if ((yield* Queue.size(queue)) === 0) return + if (remaining === 0) return yield* Effect.fail(new Error("timed out waiting for the plugin event subscription")) + yield* Effect.promise(() => Bun.sleep(1)) + yield* drained(queue, remaining - 1) + }) + +// Drives one full emission through the hook pair: the after hook only runs when the before +// hook let the call through, mirroring how a blocked call never executes. +const emit = ( + h: Harness, + tool: string, + input: unknown, + messageID: string, + result: Tool.Result, + asSession: Session.ID = sessionID, +) => + Effect.gen(function* () { + const exit = yield* Effect.exit(h.before(beforeEvent(tool, input, messageID, asSession))) + if (Exit.isSuccess(exit)) yield* h.after(completedEvent(tool, input, messageID, result, asSession)) + return exit + }) + +describe("doom loop plugin", () => { + it.effect("stacks the block copy on the mapped permission error at the A block tier", () => + Effect.gen(function* () { + const h = yield* run() + h.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + for (let i = 1; i <= 6; i++) { + yield* emit(h, "grep", input, `blk_${i}`, text(out)) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, "blk_7"))) + const error = findToolError(exit) + expect(error).toBeInstanceOf(Tool.Error) + expect(error?.message).toContain("blocked as a suspected repetition loop") + expect(error?.message).toContain("Permission denied: doom_loop") + // The bare resource string (no signal suffix) backs the always-save escape hatch. + const sig = DoomLoopDetector.signature("grep", input) + expect(h.asserted[0]?.action).toBe("doom_loop") + expect(h.asserted[0]?.resources).toEqual([`grep:${sig}`]) + expect(h.asserted[0]?.save).toEqual([`grep:${sig}`]) + }), + ) + + it.effect( + "keeps the stacked block copy model-visible through toSessionError for blocked and corrected outcomes", + () => + Effect.gen(function* () { + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + // Simulate the host mapping (host.ts:438-449): a deny rule arrives as the generic denied + // message, a rejection with feedback as the feedback text. Our hook must survive the + // toSessionError chain with both its block copy and the host reason intact — attaching the + // host error as the inner `error` of our Tool.Error would make toSessionError recurse to the + // innermost message and drop our copy. + const outcomes: Array<{ name: string; host: Tool.Error }> = [ + { + name: "blocked", + host: new Tool.Error({ + message: "Permission denied: doom_loop", + error: new Permission.BlockedError({ rules: [], permission: "doom_loop", resources: ["*"] }), + }), + }, + { + name: "corrected", + host: new Tool.Error({ + message: "Use staging instead", + error: new Permission.CorrectedError({ feedback: "Use staging instead" }), + }), + }, + ] + for (const outcome of outcomes) { + const h = yield* run() + h.setOutcome(() => Effect.fail(outcome.host)) + for (let i = 1; i <= 6; i++) { + yield* emit(h, "grep", input, `vis_${outcome.name}_${i}`, text(out)) + } + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, `vis_${outcome.name}_7`))) + const error = findToolError(exit) + expect(error).toBeInstanceOf(Tool.Error) + const sessionError = toSessionError(error) + expect(sessionError.message).toContain("blocked as a suspected repetition loop") + expect(sessionError.message).toContain(outcome.host.message) + } + }), + ) + + it.effect("allows the call on approval and keeps counting afterwards", () => + Effect.gen(function* () { + const h = yield* run() + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + for (let i = 1; i <= 7; i++) { + const exit = yield* emit(h, "grep", input, `alw_${i}`, text(out)) + expect(Exit.isSuccess(exit)).toBe(true) + } + // One ask at the 7th emission (A streak 6); approval does not clear the counters, so the + // 8th identical emission asks again. + expect(h.asserted).toHaveLength(1) + const exit = yield* emit(h, "grep", input, "alw_8", text(out)) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(2) + }), + ) + + it.effect("lets a plain rejection stay a die defect instead of a Tool.Error", () => + Effect.gen(function* () { + const h = yield* run() + h.setOutcome(() => Effect.die(new Permission.DeclinedError())) + const input = { filePath: "log.txt" } + const out = "one\ntwo\nthree" + yield* emit(h, "read", input, "dec_msg", text(out)) + yield* emit(h, "read", input, "dec_msg", text(out)) + const exit = yield* Effect.exit(h.before(beforeEvent("read", input, "dec_msg"))) + expect(Exit.isFailure(exit)).toBe(true) + expect(findToolError(exit)).toBeUndefined() + if (!Exit.isFailure(exit)) return yield* Effect.die("expected the hook to fail") + expect( + exit.cause.reasons.some( + (reason) => Cause.isDieReason(reason) && reason.defect instanceof Permission.DeclinedError, + ), + ).toBe(true) + }), + ) + + it.effect("prepends the A warning to result.content without disturbing the original content", () => + Effect.gen(function* () { + const h = yield* run() + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + yield* emit(h, "grep", input, "app_1", text(out)) + yield* emit(h, "grep", input, "app_2", text(out)) + const third = completedEvent("grep", input, "app_3", text(out)) + yield* h.before(beforeEvent("grep", input, "app_3")) + yield* h.after(third) + expect(appendedText(third)).toContain("byte-identical output 3 times") + if (third.status !== "completed") return yield* Effect.die("unreachable") + const content = third.result.content + if (!Array.isArray(content)) return yield* Effect.die("unreachable") + // The warning leads the content: the runner's ToolOutput.truncate keeps the head of + // oversized outputs, so a tail warning would be cut on huge outputs. + if (content[0]?.type !== "text") return yield* Effect.die("unreachable") + expect(content[0].text).toContain("byte-identical output 3 times") + expect(content[1]).toEqual({ type: "text", text: out }) + // Hashing happened before the prepend: the 4th identical emission is accounted as + // streak 4, so A does not warn a second time. + const fourth = completedEvent("grep", input, "app_4", text(out)) + yield* h.before(beforeEvent("grep", input, "app_4")) + yield* h.after(fourth) + expect(appendedText(fourth)).not.toContain("byte-identical") + }), + ) + + it.effect("appends the C warning to the error message of a failed call", () => + Effect.gen(function* () { + const h = yield* run() + const input = { filePath: "a.ts", oldString: "x" } + const failure = () => new Tool.Error({ message: "String not found in document" }) + for (let i = 1; i <= 3; i++) { + yield* Effect.exit(h.before(beforeEvent("edit", input, `err_${i}`))) + yield* h.after(errorEvent("edit", input, `err_${i}`, failure())) + } + const event = errorEvent("edit", input, "err_4", failure()) + yield* h.before(beforeEvent("edit", input, "err_4")) + yield* h.after(event) + expect(event.error.message.startsWith("String not found in document")).toBe(true) + expect(event.error.message).toContain("failed 4 times in a row") + // C's warn tier is text-only: no permission ask has happened yet. + expect(h.asserted).toEqual([]) + }), + ) + + it.effect("counts only the unobserved layer for background shells and asks once at the 7th emission", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "nohup long-build &" } + const running: Tool.Result = { content: [{ type: "text", text: "running" }], metadata: { status: "running" } } + for (let i = 1; i <= 6; i++) { + const exit = yield* emit(h, "shell", input, `bg_${i}`, running) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "bg_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + const sig = DoomLoopDetector.signature("shell", input) + expect(h.asserted[0]?.resources).toEqual([`shell:${sig}:unobserved`]) + // Decision ⑦: the save string carries the same per-signal suffix as resources, so an + // "always" answer on the unobserved prompt never exempts another signal's prompt. + expect(h.asserted[0]?.save).toEqual([`shell:${sig}:unobserved`]) + // One-shot: further emissions pass without asking again. + const exit8 = yield* Effect.exit(h.before(beforeEvent("shell", input, "bg_8"))) + expect(Exit.isSuccess(exit8)).toBe(true) + expect(h.asserted).toHaveLength(1) + // The six background receipts fed none of A/B/C: foreground calls of the same tool with + // identical output now feed A normally and warn at the 3rd accounting. + const foreground = "build finished\nwith output\nin three lines" + for (let i = 9; i <= 10; i++) { + const exitN = yield* emit(h, "shell", input, `bg_${i}`, text(foreground)) + expect(Exit.isSuccess(exitN)).toBe(true) + } + const third = completedEvent("shell", input, "bg_11", text(foreground)) + yield* h.before(beforeEvent("shell", input, "bg_11")) + yield* h.after(third) + expect(appendedText(third)).toContain("byte-identical output 3 times") + expect(h.asserted).toHaveLength(1) + }), + ) + + it.effect("asks once for the guard at the 3rd emission and once for the unobserved tier at the 7th", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "nohup build &" } + const running: Tool.Result = { content: [{ type: "text", text: "running" }], metadata: { status: "running" } } + for (let i = 1; i <= 8; i++) { + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "combo"))) + expect(Exit.isSuccess(exit)).toBe(true) + yield* h.after(completedEvent("shell", input, "combo", running)) + } + const sig = DoomLoopDetector.signature("shell", input) + expect(h.asserted.map((input) => input.resources)).toEqual([[`shell:${sig}:repeat`], [`shell:${sig}:unobserved`]]) + // Decision ⑦: each signal's save string carries its own suffix; allowing the guard prompt + // must not silently allow the unobserved prompt for the same signature. + expect(h.asserted.map((input) => input.save)).toEqual([[`shell:${sig}:repeat`], [`shell:${sig}:unobserved`]]) + }), + ) + + it.effect("skips the outer execute call entirely", () => + Effect.gen(function* () { + const h = yield* run() + const input = { task: "run tests" } + for (let i = 1; i <= 3; i++) { + const exit = yield* emit(h, "execute", input, "exec_msg", text("executed")) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + // The identical receipts of the skipped calls fed no detector: a fresh foreground tool + // sequence reaches its own block tier exactly on schedule. + const input2 = { command: "git status" } + const out2 = "on branch main\nnothing to commit\nclean tree" + for (let i = 1; i <= 6; i++) { + yield* emit(h, "shell", input2, `post_${i}`, text(out2)) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input2, "post_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + // The A block tier fired exactly at the 7th shell emission — the skipped execute + // receipts fed none of the counters. + expect(h.asserted).toHaveLength(1) + expect(h.asserted[0]?.resources).toEqual([`shell:${DoomLoopDetector.signature("shell", input2)}`]) + }), + ) + + it.effect("hard-stops with a Tool.Error and interrupts the session at the circuit tier", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "probe" } + // Raw output changes every call (so A never blocks) while the normalized lines stay + // identical (so every call after the first is zero-gain for B). + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + const exit = yield* emit(h, "shell", input, `ci_${i}`, text(out(i))) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ci_14"))) + const error = findToolError(exit) + expect(error).toBeInstanceOf(Tool.Error) + expect(error?.message).toContain("Stop making tool calls entirely") + // The circuit short-circuits before every ask and forks the interrupt without awaiting. + expect(h.asserted).toEqual([]) + yield* settle(() => h.interrupts() === 1) + // A retry during circuit hits the same hard stop and never reaches the repeat guard. + const retry = yield* Effect.exit(h.before(beforeEvent("shell", input, "ci_14"))) + expect(findToolError(retry)?.message).toContain("Stop making tool calls entirely") + expect(h.asserted).toEqual([]) + yield* settle(() => h.interrupts() === 2) + }), + ) + + it.effect("re-arms the circuit breaker only on real user messages, not synthetic ones", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "probe" } + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h, "shell", input, `ev_${i}`, text(out(i))) + } + const circuits = () => + Effect.gen(function* () { + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ev_live"))) + return findToolError(exit)?.message.includes("Stop making tool calls entirely") === true + }) + expect(yield* circuits()).toBe(true) + // A synthetic inbox item (our own injection channel) must not re-arm the breaker. + yield* h.publish(inboxEnqueued({ type: "synthetic", payload: { text: "keep going" }, delivery: "steer" })) + expect(yield* circuits()).toBe(true) + // A real user message clears bStreak and re-arms the breaker. + yield* h.publish(inboxEnqueued({ type: "user", payload: { text: "stop that" }, delivery: "queue" })) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ev_live"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.effect("maps step.ended file changes to a bStreak reset", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "git status" } + const seed = "fresh seed\nstatic one\nstatic two\nstatic three" + const zero = "static one\nstatic two\nstatic three" + // Seed the line store, then climb to bStreak 2 with zero-gain calls. + yield* emit(h, "shell", input, "sf_1", text(seed)) + yield* emit(h, "shell", input, "sf_2", text(zero)) + yield* emit(h, "shell", input, "sf_3", text(zero)) + // A real file change resets the streak: the next zero-gain call stays silent. + yield* h.publish(stepEnded(["src/a.ts"])) + const calm = completedEvent("shell", input, "sf_4", text(zero)) + yield* h.before(beforeEvent("shell", input, "sf_4")) + yield* h.after(calm) + expect(appendedText(calm)).not.toContain("no new information") + // An empty file list is not a file change: the streak keeps climbing and warns at 3. + const h2 = yield* run() + yield* emit(h2, "shell", input, "se_1", text(seed)) + yield* emit(h2, "shell", input, "se_2", text(zero)) + yield* emit(h2, "shell", input, "se_3", text(zero)) + yield* h2.publish(stepEnded([])) + const warned = completedEvent("shell", input, "se_4", text(zero)) + yield* h2.before(beforeEvent("shell", input, "se_4")) + yield* h2.after(warned) + expect(appendedText(warned)).toContain("no new information") + }), + ) + + it.effect("zeroes A streaks when compaction ends", () => + Effect.gen(function* () { + const h = yield* run() + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + for (let i = 1; i <= 3; i++) { + yield* emit(h, "grep", input, `cp_${i}`, text(out)) + } + yield* h.publish(compactionEnded) + // A's streak was zeroed: three identical emissions after compaction warn again at the + // 3rd accounting instead of moving towards the block tier. + yield* emit(h, "grep", input, "cp2_1", text(out)) + yield* emit(h, "grep", input, "cp2_2", text(out)) + expect(h.asserted).toEqual([]) + const third = completedEvent("grep", input, "cp2_3", text(out)) + yield* h.before(beforeEvent("grep", input, "cp2_3")) + yield* h.after(third) + expect(appendedText(third)).toContain("byte-identical output 3 times") + expect(h.asserted).toEqual([]) + }), + ) + + it.effect("tightens the line store to the newest 1000 lines when an execution ends", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "flood" } + const flood = Array.from({ length: 1100 }, (_, i) => lineTag(i)).join("\n") + yield* emit(h, "shell", input, "tn_1", text(flood)) + yield* h.publish(executionSucceeded(sessionID)) + // The execution-end tighten evicted the oldest lines: re-emitting only those reads as + // novel again, so three identical accountings stay silent. Without the tighten the third + // zero-gain accounting would warn "no new information". + const old = [lineTag(0), lineTag(1), lineTag(2)].join("\n") + for (let i = 2; i <= 4; i++) { + const event = completedEvent("shell", input, `tn_${i}`, text(old)) + yield* h.before(beforeEvent("shell", input, `tn_${i}`)) + yield* h.after(event) + expect(appendedText(event)).not.toContain("no new information") + } + }), + ) + + it.effect("tightens the folded line store to the newest 1000 lines, matching the live steady state", () => + Effect.gen(function* () { + const flood = Array.from({ length: 1100 }, (_, i) => lineTag(i)).join("\n") + const state = DoomLoopDetector.state() + DoomLoopFold.fold(state, [foldedCall("fold_tn_1", "shell", { command: "flood" }, flood)]) + expect(state.lines.order.length).toBe(1000) + expect(state.lines.set.size).toBe(1000) + expect(state.lines.order[0]).toBe(lineTag(100)) + }), + ) + + it.effect("evicts the least recently touched session past the cap and re-folds it from history", () => + Effect.gen(function* () { + const sesA = Session.ID.make("ses_lru_a") + const sesB = Session.ID.make("ses_lru_b") + const sesC = Session.ID.make("ses_lru_c") + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + const h = yield* run({ + histories: { [sesA]: [foldedCall("lru_a1", "grep", input, out), foldedCall("lru_a2", "grep", input, out)] }, + }) + DoomLoopPlugin.setMaxTrackedSessions(2) + // A folds its two historical calls; the third identical accounting warns. + const a3 = completedEvent("grep", input, "lru_a3", text(out), sesA) + yield* h.before(beforeEvent("grep", input, "lru_a3", sesA)) + yield* h.after(a3) + expect(appendedText(a3)).toContain("byte-identical output 3 times") + // B builds a live streak of two. + yield* emit(h, "grep", { pattern: "b" }, "lru_b1", text("b one"), sesB) + yield* emit(h, "grep", { pattern: "b" }, "lru_b2", text("b one"), sesB) + // C's first touch pushes the map past the cap and evicts A, the oldest entry. + yield* emit(h, "grep", { pattern: "c" }, "lru_c1", text("c one"), sesC) + // B was touched after A and survives: its third identical accounting warns. + const b3 = completedEvent("grep", { pattern: "b" }, "lru_b3", text("b one"), sesB) + yield* h.before(beforeEvent("grep", { pattern: "b" }, "lru_b3", sesB)) + yield* h.after(b3) + expect(appendedText(b3)).toContain("byte-identical output 3 times") + // A re-folds from history on its next touch: the rebuilt streak of two plus this + // accounting warns at three — a continued live streak would be four and stay silent. + const a4 = completedEvent("grep", input, "lru_a4", text(out), sesA) + yield* h.before(beforeEvent("grep", input, "lru_a4", sesA)) + yield* h.after(a4) + expect(appendedText(a4)).toContain("byte-identical output 3 times") + }), + ) + + it.effect("keeps a tripped breaker across a cache eviction via plugin KV", () => + Effect.gen(function* () { + const sesA = Session.ID.make("ses_lru_circuit_a") + const sesB = Session.ID.make("ses_lru_circuit_b") + const sesC = Session.ID.make("ses_lru_circuit_c") + const h = yield* run() + DoomLoopPlugin.setMaxTrackedSessions(2) + const input = { command: "probe" } + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h, "shell", input, `lc_${i}`, text(out(i)), sesA) + } + const tripped = yield* Effect.exit(h.before(beforeEvent("shell", input, "lc_14", sesA))) + expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") + yield* settle(() => h.interrupts() === 1) + // Two other sessions push A out of the cache. + yield* emit(h, "grep", { pattern: "b" }, "lc_b1", text("b one"), sesB) + yield* emit(h, "grep", { pattern: "c" }, "lc_c1", text("c one"), sesC) + // A's in-memory entry is gone, but the breaker survives in KV: the re-folded state still + // hard-stops the next call. + const again = yield* Effect.exit(h.before(beforeEvent("shell", input, "lc_15", sesA))) + expect(findToolError(again)?.message).toContain("Stop making tool calls entirely") + expect(h.asserted).toEqual([]) + yield* settle(() => h.interrupts() === 2) + }), + ) + + it.effect("folds durable history into A on first touch and challenges the 7th identical emission", () => + Effect.gen(function* () { + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + const history = Array.from({ length: 6 }, (_, i) => foldedCall(`a_${i}`, "grep", input, out)) + // A restarted process has done no live accounting, but the fold rebuilds the streak. + const h = yield* run({ history }) + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, "fold_a_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + expect(h.asserted[0]?.resources).toEqual([`grep:${DoomLoopDetector.signature("grep", input)}`]) + }), + ) + + it.effect("folds durable failures into C so the next failure warns at the 4th accounting", () => + Effect.gen(function* () { + const input = { filePath: "a.ts", oldString: "x" } + const history = Array.from({ length: 3 }, (_, i) => + foldedFailure(`c_${i}`, "edit", input, "String not found in document"), + ) + const h = yield* run({ history }) + const event = errorEvent("edit", input, "fold_c_4", new Tool.Error({ message: "String not found in document" })) + yield* h.before(beforeEvent("edit", input, "fold_c_4")) + yield* h.after(event) + expect(event.error.message).toContain("failed 4 times in a row") + }), + ) + + it.effect("skips blocked and declined calls persisted as error parts when folding", () => + Effect.gen(function* () { + const input = { filePath: "a.ts", oldString: "x" } + // Six before-hook rejections (marked) plus two declines: none executed, none may feed C. + const history = [ + ...Array.from({ length: 6 }, (_, i) => + foldedFailure(`blk_${i}`, "edit", input, DoomLoopDetector.copy.blockA("edit", 6 + i), { + metadata: { doom_loop: "blocked" }, + }), + ), + foldedFailure("dec_1", "edit", input, "The user declined this tool call", { type: "aborted" }), + foldedFailure("dec_2", "edit", input, "The user declined this tool call", { type: "aborted" }), + ] + const h = yield* run({ history }) + // Had the fold counted them, C would already sit past the block tier and this call — with + // entirely different arguments — would be challenged before executing. + const exit = yield* Effect.exit(h.before(beforeEvent("edit", { filePath: "b.ts" }, "sk_probe"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toEqual([]) + // Real failures count from zero: the first three stay silent, the fourth warns. + for (let i = 1; i <= 3; i++) { + const event = errorEvent("edit", input, `sk_f${i}`, new Tool.Error({ message: "String not found in document" })) + yield* h.before(beforeEvent("edit", input, `sk_f${i}`)) + yield* h.after(event) + expect(event.error.message).toBe("String not found in document") + } + const fourth = errorEvent("edit", input, "sk_f4", new Tool.Error({ message: "String not found in document" })) + yield* h.before(beforeEvent("edit", input, "sk_f4")) + yield* h.after(fourth) + expect(fourth.error.message).toContain("failed 4 times in a row") + }), + ) + + it.effect("strips injected warnings so the rebuilt A streak matches live accounting", () => + Effect.gen(function* () { + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + // The 3rd identical call carried the A warning in its persisted content; live hashed the + // output before the prepend, so the fold must strip it to rebuild the full streak. + const history = Array.from({ length: 6 }, (_, i) => + foldedCall(`w_${i}`, "grep", input, out, i === 2 ? DoomLoopDetector.copy.warnA("grep", 3) : undefined), + ) + const h = yield* run({ history }) + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, "w_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + expect(h.asserted[0]?.resources).toEqual([`grep:${DoomLoopDetector.signature("grep", input)}`]) + }), + ) + + it.effect("strips the injected C warning from folded failures so the block tier stays on schedule", () => + Effect.gen(function* () { + const input = { filePath: "a.ts", oldString: "x" } + const failure = "String not found in document" + // Five consecutive failures; the 4th carried the C warning in its persisted message. + const history = Array.from({ length: 5 }, (_, i) => + foldedFailure( + `cw_${i}`, + "edit", + input, + i === 3 ? `${failure}${DoomLoopDetector.injectedFailureSuffix("edit")}` : failure, + ), + ) + const h = yield* run({ history }) + // The rebuilt streak is 5: the 6th accounting stays silent (the warning fired only at 4)... + const event = errorEvent("edit", input, "cw_6", new Tool.Error({ message: failure })) + yield* h.before(beforeEvent("edit", input, "cw_6")) + yield* h.after(event) + expect(event.error.message).toBe(failure) + // ...and the next emission hits the C block tier exactly on schedule. Had the injected + // warning been hashed, the rebuilt streak would be 2 and this block would be lost. + const exit = yield* Effect.exit(h.before(beforeEvent("edit", input, "cw_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + expect(h.asserted[0]?.action).toBe("doom_loop") + }), + ) + + it.effect("folds background shell receipts into the unobserved layer but ignores user shell messages", () => + Effect.gen(function* () { + const input = { command: "nohup long-build &" } + const history = [ + foldedBackgroundShell("u_1", input), + foldedUserShell("u_1", "ls"), + foldedBackgroundShell("u_2", input), + foldedUserShell("u_2", "pwd"), + foldedBackgroundShell("u_3", input), + foldedUserShell("u_3", "git status"), + ] + const h = yield* run({ history }) + const running: Tool.Result = { content: [{ type: "text", text: "running" }], metadata: { status: "running" } } + // The fold counted only the three model receipts: two more stay under the prompt tier. + for (let i = 4; i <= 5; i++) { + const exit = yield* emit(h, "shell", input, `fu_${i}`, running) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + // The sixth accounting arms the one-time prompt, which fires at the next emission. + yield* emit(h, "shell", input, "fu_6", running) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "fu_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + const sig = DoomLoopDetector.signature("shell", input) + expect(h.asserted[0]?.resources).toEqual([`shell:${sig}:unobserved`]) + }), + ) + + it.effect("falls back to a fresh state when the history read fails", () => + Effect.gen(function* () { + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + // This history would rebuild a block-tier streak, but the read fails: detection restarts + // from zero rather than failing the tool call. + const history = Array.from({ length: 6 }, (_, i) => foldedCall(`f_${i}`, "grep", input, out)) + const h = yield* run({ history, contextFails: true }) + for (let i = 1; i <= 6; i++) { + const exit = yield* emit(h, "grep", input, `ff_${i}`, text(out)) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, "ff_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + }), + ) + + it.effect("persists the circuit breaker across a restart and re-arms on a real user message", () => + Effect.gen(function* () { + const storage = new Map() + const h1 = yield* run({ storage }) + const input = { command: "probe" } + // Raw output changes every call (A never blocks) while the normalized lines stay identical + // (every call after the first is zero-gain for B). + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h1, "shell", input, `rs_${i}`, text(out(i))) + } + const tripped = yield* Effect.exit(h1.before(beforeEvent("shell", input, "rs_14"))) + expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") + yield* settle(() => h1.interrupts() === 1) + expect(storage.has(`circuit:${sessionID}`)).toBe(true) + // A restarted process (fresh plugin, same durable KV) restores the breaker with no history + // at all: the very first call is hard-stopped again. + const h2 = yield* run({ storage }) + const blocked = yield* Effect.exit(h2.before(beforeEvent("shell", input, "rs_15"))) + expect(findToolError(blocked)?.message).toContain("Stop making tool calls entirely") + yield* settle(() => h2.interrupts() === 1) + // A real user message re-arms the breaker in both the state and the durable key. + yield* h2.publish(inboxEnqueued({ type: "user", payload: { text: "stop that" }, delivery: "queue" })) + yield* settle(() => !storage.has(`circuit:${sessionID}`)) + const exit = yield* Effect.exit(h2.before(beforeEvent("shell", input, "rs_16"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.effect("clears a persisted circuit when the session is deleted", () => + Effect.gen(function* () { + const storage = new Map() + const h = yield* run({ storage }) + const input = { command: "probe" } + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h, "shell", input, `dc_${i}`, text(out(i))) + } + const tripped = yield* Effect.exit(h.before(beforeEvent("shell", input, "dc_14"))) + expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") + expect(storage.has(`circuit:${sessionID}`)).toBe(true) + yield* h.publish(sessionDeleted(sessionID)) + yield* settle(() => !storage.has(`circuit:${sessionID}`)) + // Deletion dropped both the entry and the persisted breaker: the next call folds the empty + // history into a fresh state and passes. + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "dc_15"))) + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) + + it.effect("forgets a session wholesale on session.deleted and restarts its counters from zero", () => + Effect.gen(function* () { + const h = yield* run() + const input = { pattern: "TODO", path: "src" } + const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" + for (let i = 1; i <= 6; i++) { + yield* emit(h, "grep", input, `del_${i}`, text(out)) + } + yield* h.publish(sessionDeleted(sessionID)) + // The deleted session's state is gone: the 7th identical emission is not challenged... + const exit = yield* Effect.exit(h.before(beforeEvent("grep", input, "del_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toEqual([]) + // ...and counting restarts from zero: the 3rd identical accounting after deletion warns. + yield* emit(h, "grep", input, "del_8", text(out)) + yield* emit(h, "grep", input, "del_9", text(out)) + const third = completedEvent("grep", input, "del_10", text(out)) + yield* h.before(beforeEvent("grep", input, "del_10")) + yield* h.after(third) + expect(appendedText(third)).toContain("byte-identical output 3 times") + expect(h.asserted).toEqual([]) + }), + ) + + it.effect("routes the question tool through the unobserved layer and asks once at the 7th emission", () => + Effect.gen(function* () { + const h = yield* run() + const input = { question: "Continue?" } + for (let i = 1; i <= 6; i++) { + const exit = yield* emit(h, "question", input, `q_${i}`, text("approved")) + expect(Exit.isSuccess(exit)).toBe(true) + } + expect(h.asserted).toEqual([]) + const exit = yield* Effect.exit(h.before(beforeEvent("question", input, "q_7"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + const sig = DoomLoopDetector.signature("question", input) + expect(h.asserted[0]?.resources).toEqual([`question:${sig}:unobserved`]) + expect(h.asserted[0]?.save).toEqual([`question:${sig}:unobserved`]) + }), + ) +}) From 98bdd36a4fbba67b434679a9db534bfeb82d5f3e Mon Sep 17 00:00:00 2001 From: METAL-H Date: Tue, 3 Nov 2026 12:12:33 +0800 Subject: [PATCH 3/4] fix(opencode): route the circuit breaker through doom_loop and scope counters per turn --- packages/core/src/plugin/doom-loop.ts | 59 +++++++++++++------ .../core/src/plugin/doom-loop/detector.ts | 26 +++++--- packages/core/src/plugin/doom-loop/fold.ts | 2 +- .../test/plugin/doom-loop-detector.test.ts | 15 +++-- packages/core/test/plugin/doom-loop.test.ts | 42 +++++++++---- 5 files changed, 103 insertions(+), 41 deletions(-) diff --git a/packages/core/src/plugin/doom-loop.ts b/packages/core/src/plugin/doom-loop.ts index f61a101484a2..49f372f3869f 100644 --- a/packages/core/src/plugin/doom-loop.ts +++ b/packages/core/src/plugin/doom-loop.ts @@ -3,7 +3,7 @@ export * as DoomLoopPlugin from "./doom-loop.js" import { define } from "@opencode/plugin/effect/plugin" import type { Session } from "@opencode/schema/session" import type { SessionEvent } from "@opencode/schema/session-event" -import { Effect, Scope, Stream } from "effect" +import { Effect, Exit, Scope, Stream } from "effect" import { Tool } from "../tool.js" import { DoomLoopDetector } from "./doom-loop/detector.js" import { DoomLoopFold } from "./doom-loop/fold.js" @@ -21,13 +21,6 @@ import { DoomLoopFold } from "./doom-loop/fold.js" // session no hook is touching. const DEFAULT_MAX_TRACKED_SESSIONS = 64 -let maxTrackedSessions = DEFAULT_MAX_TRACKED_SESSIONS - -// Test seam: suites lower the cap to exercise eviction without booting 65 sessions. Call with no -// argument to restore the default. -export function setMaxTrackedSessions(limit: number = DEFAULT_MAX_TRACKED_SESSIONS) { - maxTrackedSessions = limit -} type Tracked = | SessionEvent.Deleted @@ -44,9 +37,13 @@ interface Entry { const circuitKey = (sessionID: Session.ID) => `circuit:${sessionID}` -export const Plugin = define({ - id: "opencode.doom.loop", - effect: Effect.fn(function* (ctx) { +// Factory-injected cap: tests construct a plugin with a small cap instead of mutating shared state. +export function makePlugin(options?: { maxTrackedSessions?: number }) { + const cap = options?.maxTrackedSessions ?? DEFAULT_MAX_TRACKED_SESSIONS + + return define({ + id: "opencode.doom.loop", + effect: Effect.fn(function* (ctx) { const scope = yield* Scope.Scope const sessions = new Map>() @@ -76,7 +73,7 @@ export const Plugin = define({ sessions.set(sessionID, created) // Eviction is a bare delete: the KV breaker key belongs to the session lifecycle, not to // this cache, and everything else re-folds from durable history on the next touch. - while (sessions.size > maxTrackedSessions) { + while (sessions.size > cap) { const oldest = sessions.keys().next().value if (oldest === undefined) break sessions.delete(oldest) @@ -113,11 +110,36 @@ export const Plugin = define({ }) if (verdict.kind === "pass") return if (verdict.kind === "circuit") { + // A sticky circuit (a previous denial) stops without re-asking, until the user speaks. + if (verdict.sticky) { + yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) + return yield* new Tool.Error({ message: verdict.message, metadata: DoomLoopDetector.blockedMetadata }) + } + // The breaker goes through doom_loop like every other signal: allow resets B and lets + // the run continue; a denial makes the stop sticky and explains why it happened. + const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix.b}` + const outcome = yield* Effect.exit( + ctx.permission.assert({ + sessionID: event.sessionID, + agent: event.agent, + action: "doom_loop", + resources: [resource], + save: [resource], + source: { type: "tool", messageID: event.messageID, id: event.id }, + }), + ) + if (Exit.isSuccess(outcome)) { + current.state.bStreak = 0 + return + } yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore) - // Hard stop: interrupt the drain without awaiting — the fork outlives the aborted step - // fiber. The handoff message is best-effort and expected to be lost with the step. + current.state.circuitBroken = true yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) - return yield* new Tool.Error({ message: verdict.message, metadata: DoomLoopDetector.blockedMetadata }) + const reason = Exit.isFailure(outcome) ? outcome.cause.toString() : "" + return yield* new Tool.Error({ + message: `${verdict.message}\n\n${reason}`, + metadata: DoomLoopDetector.blockedMetadata, + }) } const resource = `${event.tool}:${sigHash}${DoomLoopDetector.resourceSuffix[verdict.signal]}` // host.ts maps BlockedError/CorrectedError to Tool.Error and keeps a plain rejection a @@ -162,7 +184,7 @@ export const Plugin = define({ const warning = DoomLoopDetector.recordFailed(current.state, { tool: event.tool, errorText: event.error.message, - validation: event.error.message.startsWith("Invalid arguments for tool"), + validation: event.error.message.startsWith(DoomLoopDetector.VALIDATION_PREFIX), }) if (warning !== undefined) event.error.message = `${event.error.message}\n\n${warning}` return @@ -237,4 +259,7 @@ export const Plugin = define({ Effect.forkScoped({ startImmediately: true }), ) }), -}) + }) +} + +export const Plugin = makePlugin() diff --git a/packages/core/src/plugin/doom-loop/detector.ts b/packages/core/src/plugin/doom-loop/detector.ts index 12803395cc6f..991d4c086a21 100644 --- a/packages/core/src/plugin/doom-loop/detector.ts +++ b/packages/core/src/plugin/doom-loop/detector.ts @@ -130,6 +130,11 @@ export const copy = { // one prompt never silently exempts another signal's prompt for the same signature. export const resourceSuffix = { guard: ":repeat", unobserved: ":unobserved", a: "", b: "", c: "" } as const +// Host-specific strings that mark argument-validation rejections (the tool never executed). +// Kept in one place so a host copy change is a one-line fix; an unmatched message is treated as a +// real failure (conservative: counts into C rather than silently exempting it). +export const VALIDATION_PREFIX = "Invalid arguments for tool" + // Texts the live after hook can inject into a persisted result: warnings prepend to a completed // result's content and append to a failed call's error message. They fire only at fixed streaks, // so the complete set is enumerable. The fold strips them to hash the same text the live path @@ -151,7 +156,7 @@ export const hasBlockedMetadata = (metadata: Record | undefined export type Verdict = | { readonly kind: "pass" } - | { readonly kind: "circuit"; readonly message: string } + | { readonly kind: "circuit"; readonly message: string; readonly sticky: boolean } | { readonly kind: "assert" readonly signal: keyof typeof resourceSuffix @@ -201,12 +206,11 @@ export interface Call { // Short-circuit order: circuit breaker -> same-message guard (==3, one-shot) -> unobserved-layer // one-time prompt -> A >= 6 -> B >= 8 surgical (requires A streak >= 3 corroboration) -> C >= 6. -// The first hit is the only action for this call; a circuit hit hard-stops without asking. +// The first hit is the only action for this call. A circuit hit is a permission ask like every +// other signal: allow resets B and lets the run continue; a denial makes it sticky (hard stop). export function beforeCall(current: SessionState, call: Call): Verdict { - if (current.circuitBroken || current.bStreak >= B_CIRCUIT) { - current.circuitBroken = true - return { kind: "circuit", message: copy.circuit } - } + if (current.circuitBroken) return { kind: "circuit", message: copy.circuit, sticky: true } + if (current.bStreak >= B_CIRCUIT) return { kind: "circuit", message: copy.circuit, sticky: false } if (current.emitted.messageID !== call.messageID) { current.emitted = { messageID: call.messageID, counts: new Map() } } @@ -351,9 +355,13 @@ export function compacted(current: SessionState) { current.bStreak = 0 } -// User message: the circuit breaker's defined escape hatch. B resets, the breaker re-arms; A/C -// survive across user messages (a user interjection must not hand the loop a fresh counter). +// User message: the circuit breaker's defined escape hatch and the per-turn scope boundary. B and +// the breaker reset, and A/C counters reset too — a new user turn opens a fresh observation window, +// so a periodic legitimate re-check cannot accumulate into a block over days. A real loop +// re-accumulates within seconds after an interrupt, so detection is preserved. export function userMessage(current: SessionState) { + current.signatures.clear() + current.failures.clear() current.bStreak = 0 current.circuitBroken = false } @@ -364,7 +372,7 @@ export function filesChanged(current: SessionState) { } // Execution-end sweep (memory bound): tighten the line store of the session that just finished. -// A/C counters are preserved — they intentionally survive across user messages and executions. +// A/C counters are preserved here — they only reset on a new user message (per-turn scoping). export function tighten(current: SessionState) { if (current.lines.order.length <= TIGHTENED_LINE_KEEP) return const evicted = current.lines.order.splice(0, current.lines.order.length - TIGHTENED_LINE_KEEP) diff --git a/packages/core/src/plugin/doom-loop/fold.ts b/packages/core/src/plugin/doom-loop/fold.ts index f017d0df4a2d..2b4fadb9d18f 100644 --- a/packages/core/src/plugin/doom-loop/fold.ts +++ b/packages/core/src/plugin/doom-loop/fold.ts @@ -55,7 +55,7 @@ export function fold(state: DoomLoopDetector.SessionState, messages: ReadonlyArr DoomLoopDetector.recordFailed(state, { tool: part.name, errorText, - validation: errorText.startsWith("Invalid arguments for tool"), + validation: errorText.startsWith(DoomLoopDetector.VALIDATION_PREFIX), }) continue } diff --git a/packages/core/test/plugin/doom-loop-detector.test.ts b/packages/core/test/plugin/doom-loop-detector.test.ts index 33e500ea251b..c0e0dec1a1b1 100644 --- a/packages/core/test/plugin/doom-loop-detector.test.ts +++ b/packages/core/test/plugin/doom-loop-detector.test.ts @@ -511,7 +511,7 @@ describe("doom loop detector", () => { expect(state.signatures.get(sig)?.streak).toBe(1) }) - it("user messages clear only bStreak and the circuit breaker", () => { + it("user messages reset A/C counters and re-arm the breaker", () => { const state = DoomLoopDetector.state() const sig = DoomLoopDetector.signature("grep", { pattern: "TODO" }) for (let i = 0; i < 5; i++) { @@ -520,9 +520,10 @@ describe("doom loop detector", () => { } DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "boom", validation: false }) DoomLoopDetector.userMessage(state) - // A/C survive a user interjection — clearing them would hand the loop a fresh counter. - expect(state.signatures.get(sig)?.streak).toBe(5) - expect(state.failures.has("edit")).toBe(true) + // Per-turn scoping: a new user message opens a fresh observation window for A and C too, + // so a periodic legitimate re-check cannot accumulate into a block over days. + expect(state.signatures.has(sig)).toBe(false) + expect(state.failures.has("edit")).toBe(false) expect(state.bStreak).toBe(0) }) @@ -552,6 +553,12 @@ describe("doom loop detector", () => { expect(v1.kind).toBe("circuit") expect(v2.kind).toBe("circuit") expect(v1.kind === "circuit" && v1.message).toContain("Stop making tool calls") + // First trip is a permission ask (not yet sticky); a denied circuit becomes sticky. + expect(v1.kind === "circuit" && v1.sticky).toBe(false) + state.circuitBroken = true + const sticky = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "ci_2b" }) + expect(sticky.kind).toBe("circuit") + expect(sticky.kind === "circuit" && sticky.sticky).toBe(true) DoomLoopDetector.userMessage(state) const v3 = DoomLoopDetector.beforeCall(state, { tool: "read", sigHash: sig, messageID: "ci_3" }) expect(v3.kind).toBe("pass") diff --git a/packages/core/test/plugin/doom-loop.test.ts b/packages/core/test/plugin/doom-loop.test.ts index 7887a7f31242..36253816bf08 100644 --- a/packages/core/test/plugin/doom-loop.test.ts +++ b/packages/core/test/plugin/doom-loop.test.ts @@ -57,11 +57,12 @@ interface HarnessOptions { readonly contextFails?: boolean /** Durable plugin KV; share one map across harness instances to simulate a restart. */ readonly storage?: Map + /** Factory-injected session cap (no global seam). */ + readonly maxTrackedSessions?: number } /** Boots the plugin against stubbed domains and returns its registered hooks plus spies. */ const run = Effect.fnUntraced(function* (options?: HarnessOptions) { - DoomLoopPlugin.setMaxTrackedSessions() const base = host() const kv = options?.storage ?? new Map() let beforeHook: BeforeHook | undefined @@ -70,7 +71,7 @@ const run = Effect.fnUntraced(function* (options?: HarnessOptions) { const outcome: { current: Outcome } = { current: () => Effect.void } let interrupts = 0 const queue = yield* Queue.unbounded() - yield* DoomLoopPlugin.Plugin.effect( + yield* DoomLoopPlugin.makePlugin({ maxTrackedSessions: options?.maxTrackedSessions }).effect( host({ permission: { ...base.permission, @@ -609,22 +610,39 @@ describe("doom loop plugin", () => { const exit = yield* emit(h, "shell", input, `ci_${i}`, text(out(i))) expect(Exit.isSuccess(exit)).toBe(true) } - expect(h.asserted).toEqual([]) + h.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ci_14"))) const error = findToolError(exit) expect(error).toBeInstanceOf(Tool.Error) expect(error?.message).toContain("Stop making tool calls entirely") - // The circuit short-circuits before every ask and forks the interrupt without awaiting. - expect(h.asserted).toEqual([]) + // The denied circuit asks once through doom_loop, then becomes sticky without re-asking. + expect(h.asserted).toHaveLength(1) yield* settle(() => h.interrupts() === 1) // A retry during circuit hits the same hard stop and never reaches the repeat guard. const retry = yield* Effect.exit(h.before(beforeEvent("shell", input, "ci_14"))) expect(findToolError(retry)?.message).toContain("Stop making tool calls entirely") - expect(h.asserted).toEqual([]) + expect(h.asserted).toHaveLength(1) yield* settle(() => h.interrupts() === 2) }), ) + it.effect("resets B and lets the run continue when the circuit ask is allowed", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "probe" } + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h, "shell", input, `al_${i}`, text(out(i))) + } + // Default outcome allows the circuit ask: no interrupt, and B resets so the run continues. + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "al_14"))) + expect(Exit.isSuccess(exit)).toBe(true) + expect(h.asserted).toHaveLength(1) + const next = yield* Effect.exit(h.before(beforeEvent("shell", input, "al_15"))) + expect(Exit.isSuccess(next)).toBe(true) + }), + ) + it.effect("re-arms the circuit breaker only on real user messages, not synthetic ones", () => Effect.gen(function* () { const h = yield* run() @@ -633,6 +651,7 @@ describe("doom loop plugin", () => { for (let i = 1; i <= 13; i++) { yield* emit(h, "shell", input, `ev_${i}`, text(out(i))) } + h.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) const circuits = () => Effect.gen(function* () { const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ev_live"))) @@ -740,8 +759,8 @@ describe("doom loop plugin", () => { const out = "src/a.ts:1:TODO\nsrc/b.ts:2:TODO\nsrc/c.ts:3:TODO" const h = yield* run({ histories: { [sesA]: [foldedCall("lru_a1", "grep", input, out), foldedCall("lru_a2", "grep", input, out)] }, + maxTrackedSessions: 2, }) - DoomLoopPlugin.setMaxTrackedSessions(2) // A folds its two historical calls; the third identical accounting warns. const a3 = completedEvent("grep", input, "lru_a3", text(out), sesA) yield* h.before(beforeEvent("grep", input, "lru_a3", sesA)) @@ -771,13 +790,14 @@ describe("doom loop plugin", () => { const sesA = Session.ID.make("ses_lru_circuit_a") const sesB = Session.ID.make("ses_lru_circuit_b") const sesC = Session.ID.make("ses_lru_circuit_c") - const h = yield* run() - DoomLoopPlugin.setMaxTrackedSessions(2) + const h = yield* run({ maxTrackedSessions: 2 }) const input = { command: "probe" } const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` for (let i = 1; i <= 13; i++) { yield* emit(h, "shell", input, `lc_${i}`, text(out(i)), sesA) } + // A denied circuit trips the sticky stop. + h.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) const tripped = yield* Effect.exit(h.before(beforeEvent("shell", input, "lc_14", sesA))) expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") yield* settle(() => h.interrupts() === 1) @@ -788,7 +808,7 @@ describe("doom loop plugin", () => { // hard-stops the next call. const again = yield* Effect.exit(h.before(beforeEvent("shell", input, "lc_15", sesA))) expect(findToolError(again)?.message).toContain("Stop making tool calls entirely") - expect(h.asserted).toEqual([]) + expect(h.asserted).toHaveLength(1) yield* settle(() => h.interrupts() === 2) }), ) @@ -958,6 +978,7 @@ describe("doom loop plugin", () => { for (let i = 1; i <= 13; i++) { yield* emit(h1, "shell", input, `rs_${i}`, text(out(i))) } + h1.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) const tripped = yield* Effect.exit(h1.before(beforeEvent("shell", input, "rs_14"))) expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") yield* settle(() => h1.interrupts() === 1) @@ -985,6 +1006,7 @@ describe("doom loop plugin", () => { for (let i = 1; i <= 13; i++) { yield* emit(h, "shell", input, `dc_${i}`, text(out(i))) } + h.setOutcome(() => Effect.fail(new Tool.Error({ message: "Permission denied: doom_loop" }))) const tripped = yield* Effect.exit(h.before(beforeEvent("shell", input, "dc_14"))) expect(findToolError(tripped)?.message).toContain("Stop making tool calls entirely") expect(storage.has(`circuit:${sessionID}`)).toBe(true) From 04e23fdb0a7279ad4a318ae067c64815cdda2e02 Mon Sep 17 00:00:00 2001 From: METAL-H Date: Tue, 3 Nov 2026 12:35:07 +0800 Subject: [PATCH 4/4] fix(opencode): key failure streaks by signature and handle corrected errors --- packages/core/src/plugin/doom-loop.ts | 23 +++++++++---- .../core/src/plugin/doom-loop/detector.ts | 18 +++++----- packages/core/src/plugin/doom-loop/fold.ts | 1 + .../test/plugin/doom-loop-detector.test.ts | 34 +++++++++++-------- packages/core/test/plugin/doom-loop.test.ts | 28 +++++++++++++-- 5 files changed, 74 insertions(+), 30 deletions(-) diff --git a/packages/core/src/plugin/doom-loop.ts b/packages/core/src/plugin/doom-loop.ts index 49f372f3869f..1317f5f35184 100644 --- a/packages/core/src/plugin/doom-loop.ts +++ b/packages/core/src/plugin/doom-loop.ts @@ -3,7 +3,8 @@ export * as DoomLoopPlugin from "./doom-loop.js" import { define } from "@opencode/plugin/effect/plugin" import type { Session } from "@opencode/schema/session" import type { SessionEvent } from "@opencode/schema/session-event" -import { Effect, Exit, Scope, Stream } from "effect" +import { Effect, Exit, Option, Scope, Stream } from "effect" +import { Permission } from "../permission.js" import { Tool } from "../tool.js" import { DoomLoopDetector } from "./doom-loop/detector.js" import { DoomLoopFold } from "./doom-loop/fold.js" @@ -132,12 +133,21 @@ export function makePlugin(options?: { maxTrackedSessions?: number }) { current.state.bStreak = 0 return } - yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore) - current.state.circuitBroken = true - yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) - const reason = Exit.isFailure(outcome) ? outcome.cause.toString() : "" + const error = Option.getOrUndefined(Exit.findErrorOption(outcome)) + // A rule-based denial makes the stop sticky; a CorrectedError carries model feedback and + // returns as a plain Tool.Error so the model can act on it (no sticky stop, no interrupt). + // Only a CorrectedError returns as plain feedback for the model (no sticky stop). + // The host wraps it as Tool.Error with the CorrectedError in `.error`, so check both. + const corrected = + error instanceof Permission.CorrectedError || + (error instanceof Tool.Error && error.error instanceof Permission.CorrectedError) + if (!corrected) { + yield* ctx.storage.set(circuitKey(event.sessionID), true).pipe(Effect.ignore) + current.state.circuitBroken = true + yield* ctx.session.interrupt({ sessionID: event.sessionID }).pipe(Effect.ignore, Effect.forkIn(scope)) + } return yield* new Tool.Error({ - message: `${verdict.message}\n\n${reason}`, + message: `${verdict.message}\n\n${error?.message ?? "rejected"}`, metadata: DoomLoopDetector.blockedMetadata, }) } @@ -183,6 +193,7 @@ export function makePlugin(options?: { maxTrackedSessions?: number }) { // direction (detect more; C still only ever asks, never hard-blocks silently). const warning = DoomLoopDetector.recordFailed(current.state, { tool: event.tool, + sigHash, errorText: event.error.message, validation: event.error.message.startsWith(DoomLoopDetector.VALIDATION_PREFIX), }) diff --git a/packages/core/src/plugin/doom-loop/detector.ts b/packages/core/src/plugin/doom-loop/detector.ts index 991d4c086a21..16b270169c8d 100644 --- a/packages/core/src/plugin/doom-loop/detector.ts +++ b/packages/core/src/plugin/doom-loop/detector.ts @@ -236,7 +236,7 @@ export function beforeCall(current: SessionState, call: Call): Verdict { if (current.bStreak >= B_BLOCK && entry !== undefined && entry.streak >= A_WARN) { return { kind: "assert", signal: "b", message: copy.blockB } } - const failure = current.failures.get(call.tool) + const failure = current.failures.get(call.sigHash) if (failure !== undefined && failure.streak >= C_BLOCK) { return { kind: "assert", signal: "c", message: copy.blockC(call.tool, failure.streak) } } @@ -266,8 +266,8 @@ function lruSet(map: Map, key: string, value: V) { // signal C only, so a run of failed calls cannot masquerade as a run of identical empty outputs. // Returns warning text to append to the model-visible output, if any. export function recordCompleted(current: SessionState, call: Completed): string | undefined { - // Only a success of the same tool proves the retry loop is over. - current.failures.delete(call.tool) + // Only a success of the same signature proves the retry loop is over. + current.failures.delete(call.sigHash) const outputHash = fnv64(call.text) const previous = current.signatures.get(call.sigHash) @@ -317,24 +317,26 @@ function addLine(current: SessionState, line: string) { export interface Failed { readonly tool: string + readonly sigHash: string readonly errorText: string // Argument-validation rejections that never executed do not count (OpenClaw argument-validation // semantics); the model must be free to fix its arguments. readonly validation: boolean } -// Failure equivalence: same tool, same normalized error text counts; a different error means the -// model is making progress and resets the streak. Interleaved successes of other tools (reads) -// deliberately do not reset it. +// Failure equivalence is keyed by signature (tool + canonical input), not by tool name alone: +// different commands of one tool failing with the same normalized error would otherwise block an +// unrelated call of that tool. A different error means progress and resets the streak. Interleaved +// successes of other tools deliberately do not reset it. export function recordFailed(current: SessionState, call: Failed): string | undefined { if (call.validation) return undefined const failureHash = fnv64(normalizeVolatile(call.errorText)) - const previous = current.failures.get(call.tool) + const previous = current.failures.get(call.sigHash) const entry: FailureEntry = previous !== undefined && previous.lastFailureHash === failureHash ? { lastFailureHash: failureHash, streak: previous.streak + 1 } : { lastFailureHash: failureHash, streak: 1 } - current.failures.set(call.tool, entry) + lruSet(current.failures, call.sigHash, entry) if (entry.streak === C_WARN) return copy.warnC(call.tool, entry.streak) return undefined } diff --git a/packages/core/src/plugin/doom-loop/fold.ts b/packages/core/src/plugin/doom-loop/fold.ts index 2b4fadb9d18f..38ce54267e9f 100644 --- a/packages/core/src/plugin/doom-loop/fold.ts +++ b/packages/core/src/plugin/doom-loop/fold.ts @@ -54,6 +54,7 @@ export function fold(state: DoomLoopDetector.SessionState, messages: ReadonlyArr const errorText = message.endsWith(suffix) ? message.slice(0, -suffix.length) : message DoomLoopDetector.recordFailed(state, { tool: part.name, + sigHash: DoomLoopDetector.signature(part.name, part.state.input), errorText, validation: errorText.startsWith(DoomLoopDetector.VALIDATION_PREFIX), }) diff --git a/packages/core/test/plugin/doom-loop-detector.test.ts b/packages/core/test/plugin/doom-loop-detector.test.ts index c0e0dec1a1b1..00c06540e0e0 100644 --- a/packages/core/test/plugin/doom-loop-detector.test.ts +++ b/packages/core/test/plugin/doom-loop-detector.test.ts @@ -24,6 +24,7 @@ function emit( if (out?.error !== undefined) { warning = DoomLoopDetector.recordFailed(state, { tool, + sigHash, errorText: out.error, validation: out.error.startsWith("Invalid arguments for tool"), }) @@ -85,7 +86,7 @@ describe("doom loop detector", () => { expect(state.bStreak).toBe(0) expect(state.lines.order).toEqual([]) // C saw six different failure identities: the streak never climbs past 1. - expect(state.failures.get("shell")?.streak).toBe(1) + expect([...state.failures.values()].every((entry) => entry.streak === 1)).toBe(true) }) it("asks rather than hard-refuses for user-requested polling (corpus §2.1)", () => { @@ -402,14 +403,14 @@ describe("doom loop detector", () => { } expect(state.lines.order).toHaveLength(2001) state.signatures.set("sig-a", { lastOutputHash: "h", streak: 5 }) - state.failures.set("edit", { lastFailureHash: "h", streak: 4 }) + state.failures.set("sig-c", { lastFailureHash: "h", streak: 4 }) DoomLoopDetector.tighten(state) expect(state.lines.order).toHaveLength(1000) expect(state.lines.set.has("tight0a")).toBe(false) expect(state.lines.set.has("tight666c")).toBe(true) // The execution-end sweep must not delete A/C counters. expect(state.signatures.get("sig-a")).toEqual({ lastOutputHash: "h", streak: 5 }) - expect(state.failures.get("edit")).toEqual({ lastFailureHash: "h", streak: 4 }) + expect(state.failures.get("sig-c")).toEqual({ lastFailureHash: "h", streak: 4 }) }) }) @@ -419,10 +420,11 @@ describe("doom loop detector", () => { let warnAt = 0 let blockedAt = 0 let blockedSignal = "" + const sig = DoomLoopDetector.signature("edit", { filePath: "a.ts", oldString: "x" }) for (let i = 1; i <= 8; i++) { const before = DoomLoopDetector.beforeCall(state, { tool: "edit", - sigHash: DoomLoopDetector.signature("edit", { i }), + sigHash: sig, messageID: `c_${i}`, }) if (before.kind === "assert" && blockedAt === 0) { @@ -431,6 +433,7 @@ describe("doom loop detector", () => { } const warning = DoomLoopDetector.recordFailed(state, { tool: "edit", + sigHash: sig, errorText: "String not found in document", validation: false, }) @@ -444,14 +447,16 @@ describe("doom loop detector", () => { it("treats volatile-token differences as equivalent failures", () => { const state = DoomLoopDetector.state() + const sig = DoomLoopDetector.signature("shell", { command: "probe" }) for (let i = 1; i <= 5; i++) { DoomLoopDetector.recordFailed(state, { tool: "shell", + sigHash: sig, errorText: `Request failed after ${i}.${i}s: timeout`, validation: false, }) } - expect(state.failures.get("shell")?.streak).toBe(5) + expect(state.failures.get(sig)?.streak).toBe(5) }) it("survives interleaved successes of other tools and clears on same-tool success", () => { @@ -459,7 +464,7 @@ describe("doom loop detector", () => { const sig = DoomLoopDetector.signature("edit", { filePath: "a.ts", oldString: "x" }) for (let i = 0; i < 3; i++) { DoomLoopDetector.beforeCall(state, { tool: "edit", sigHash: sig, messageID: `rc_${i}` }) - DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "String not found", validation: false }) + DoomLoopDetector.recordFailed(state, { tool: "edit", sigHash: sig, errorText: "String not found", validation: false }) DoomLoopDetector.recordCompleted(state, { tool: "read", sigHash: DoomLoopDetector.signature("read", { f: `f${i}` }), @@ -467,9 +472,9 @@ describe("doom loop detector", () => { }) } // retry-then-read: successful reads of other files must not launder the failing edit. - expect(state.failures.get("edit")?.streak).toBe(3) + expect(state.failures.get(sig)?.streak).toBe(3) DoomLoopDetector.recordCompleted(state, { tool: "edit", sigHash: sig, text: "ok" }) - expect(state.failures.has("edit")).toBe(false) + expect(state.failures.has(sig)).toBe(false) }) it("never counts argument-validation failures", () => { @@ -477,6 +482,7 @@ describe("doom loop detector", () => { for (let i = 0; i < 8; i++) { DoomLoopDetector.recordFailed(state, { tool: "edit", + sigHash: DoomLoopDetector.signature("edit", { filePath: "a.ts", oldString: "x" }), errorText: 'Invalid arguments for tool "edit":\nmissing oldString', validation: true, }) @@ -495,7 +501,7 @@ describe("doom loop detector", () => { DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out }) } for (let i = 0; i < 4; i++) { - DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "String not found", validation: false }) + DoomLoopDetector.recordFailed(state, { tool: "edit", sigHash: sig, errorText: "String not found", validation: false }) } const hashBefore = state.signatures.get(sig)?.lastOutputHash DoomLoopDetector.compacted(state) @@ -504,7 +510,7 @@ describe("doom loop detector", () => { expect(state.lines.order).toEqual([]) expect(state.bStreak).toBe(0) // C intentionally survives compaction: failure equivalence does not depend on context. - expect(state.failures.get("edit")?.streak).toBe(4) + expect(state.failures.get(sig)?.streak).toBe(4) // The kept hash makes the first post-compaction repeat a soft boundary (streak 1, not 2). DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: "co_5" }) DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: out }) @@ -518,12 +524,12 @@ describe("doom loop detector", () => { DoomLoopDetector.beforeCall(state, { tool: "grep", sigHash: sig, messageID: `u_${i}` }) DoomLoopDetector.recordCompleted(state, { tool: "grep", sigHash: sig, text: "a:1\nb:2\nc:3" }) } - DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "boom", validation: false }) + DoomLoopDetector.recordFailed(state, { tool: "edit", sigHash: sig, errorText: "boom", validation: false }) DoomLoopDetector.userMessage(state) // Per-turn scoping: a new user message opens a fresh observation window for A and C too, // so a periodic legitimate re-check cannot accumulate into a block over days. expect(state.signatures.has(sig)).toBe(false) - expect(state.failures.has("edit")).toBe(false) + expect(state.failures.has(sig)).toBe(false) expect(state.bStreak).toBe(0) }) @@ -534,12 +540,12 @@ describe("doom loop detector", () => { DoomLoopDetector.beforeCall(state, { tool: "shell", sigHash: sig, messageID: `f_${i}` }) DoomLoopDetector.recordCompleted(state, { tool: "shell", sigHash: sig, text: "l1\nl2\nl3" }) } - DoomLoopDetector.recordFailed(state, { tool: "edit", errorText: "boom", validation: false }) + DoomLoopDetector.recordFailed(state, { tool: "edit", sigHash: sig, errorText: "boom", validation: false }) expect(state.bStreak).toBe(2) DoomLoopDetector.filesChanged(state) expect(state.bStreak).toBe(0) expect(state.signatures.get(sig)?.streak).toBe(3) - expect(state.failures.get("edit")?.streak).toBe(1) + expect(state.failures.get(sig)?.streak).toBe(1) }) }) diff --git a/packages/core/test/plugin/doom-loop.test.ts b/packages/core/test/plugin/doom-loop.test.ts index 36253816bf08..551d78b22246 100644 --- a/packages/core/test/plugin/doom-loop.test.ts +++ b/packages/core/test/plugin/doom-loop.test.ts @@ -643,6 +643,30 @@ describe("doom loop plugin", () => { }), ) + it.effect("returns a host-wrapped CorrectedError as feedback without a sticky stop", () => + Effect.gen(function* () { + const h = yield* run() + const input = { command: "probe" } + const out = (i: number) => `probe ${i}\nstatic line one\nstatic line two` + for (let i = 1; i <= 13; i++) { + yield* emit(h, "shell", input, `ce_${i}`, text(out(i))) + } + // The host wraps CorrectedError into Tool.Error with the correction in `.error`: it must + // return as feedback, not as a sticky stop (no KV circuit, no interrupt). + h.setOutcome(() => + Effect.fail( + new Tool.Error({ + message: "Use staging instead", + error: new Permission.CorrectedError({ feedback: "Use staging instead" }), + }), + ), + ) + const exit = yield* Effect.exit(h.before(beforeEvent("shell", input, "ce_14"))) + expect(Exit.isSuccess(exit)).toBe(false) + expect(h.interrupts()).toBe(0) + }), + ) + it.effect("re-arms the circuit breaker only on real user messages, not synthetic ones", () => Effect.gen(function* () { const h = yield* run() @@ -856,8 +880,8 @@ describe("doom loop plugin", () => { ] const h = yield* run({ history }) // Had the fold counted them, C would already sit past the block tier and this call — with - // entirely different arguments — would be challenged before executing. - const exit = yield* Effect.exit(h.before(beforeEvent("edit", { filePath: "b.ts" }, "sk_probe"))) + // the same arguments — would be challenged before executing. + const exit = yield* Effect.exit(h.before(beforeEvent("edit", input, "sk_probe"))) expect(Exit.isSuccess(exit)).toBe(true) expect(h.asserted).toEqual([]) // Real failures count from zero: the first three stay silent, the fourth warns.