From 5425e01f647c896192672dfb940000fc920f749c Mon Sep 17 00:00:00 2001 From: famewolf Date: Fri, 30 Oct 2026 17:21:41 -0400 Subject: [PATCH 1/4] fix(v2): re-open soft completion latches after 5m A completion latch inferred from text (stop phrase, done claim, celebration) could only be cleared by a genuine user message, so one stop phrase parked a live session until a human typed something (ses_e72df2be, 2026-10-30: latched mid-turn, silent 20+ min). - hard latch (task_complete tool, literal TASK COMPLETE tail) never expires - soft latch re-opens once after COMPLETION_LATCH_TTL_MS (5m) with no user message, via the one-shot latchReopened guard - COMPACTION_STALE_TTL_MS 30m -> 5m: a slow or wedged compaction must not mute the watchdog for half an hour --- src/v2/index.ts | 72 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index 0cd5f83..824c13d 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -303,6 +303,14 @@ interface SessionWatch { * (ses_ed33c0da, 2026-10-11: TASK COMPLETE latched at idle would have died * at the re-mark 1s later). Cleared alongside completionSignaled. */ completionSignaledAt: number + /** True when the latch came from a hard signal: the task_complete tool, or a + * literal TASK COMPLETE / task completed tail. Hard latches never re-open on + * COMPLETION_LATCH_TTL_MS — the turn genuinely said it was done. */ + completionHard: boolean + /** One-shot guard: the TTL self-check for a soft completion latch already ran. + * Without it a model that keeps answering "stopping here" would be nudged + * every TTL forever. */ + latchReopened: boolean /** Set once the empty-idle instant nudge has fired for the current episode. * A turn that ends with nothing delivered gets exactly one quiet continue; * a new turn (markBusy) re-arms it. Without the flag every repeat idle @@ -962,8 +970,18 @@ const OWN_PROMPT_TIME_MS = 30_000 const MAX_IDLE_SESSIONS = 50 const IDLE_CLEANUP_MS = 10 * 60_000 -/** A compaction silent for this long without an ended/failed event is wedged — stale-clear the flag. */ -const COMPACTION_STALE_TTL_MS = 30 * 60_000 +/** A compaction silent for this long without an ended/failed event is wedged — stale-clear the flag. + * Compaction on a single GPU legitimately runs 5-15 min here, so the guard cannot + * mute the watchdog for 30: a wedged or slow compaction must not hide a dead session. */ +const COMPACTION_STALE_TTL_MS = 5 * 60_000 +/** A SOFT completion latch (a stop phrase / done claim / celebration inferred from + * text, with no task_complete call and no TASK COMPLETE tail) re-opens once after + * this long with no genuine user message. A latch that only a human turn can clear + * parks a working session behind one stop phrase until someone types — the exact + * "stood down and never came back" failure (ses_e72df2be9ffe, 2026-10-30: latched + * at 16:19:33 mid-turn, silent until the user wrote something 20+ min later). + * A HARD latch (task_complete tool, or a literal TASK COMPLETE tail) never re-opens. */ +const COMPLETION_LATCH_TTL_MS = 5 * 60_000 /** A `permission.asked` unanswered this long is treated as abandoned — stale-clear the flag. */ const PERMISSION_STALE_TTL_MS = 30 * 60_000 /** An exempt tool (see `neverInterruptTools`) with no result after this long is @@ -1344,6 +1362,21 @@ function turnEndsWithCompletionClaim(text: string): boolean { return endsWithCelebration(text) || containsStopStatement(text) } +/** A completion signal that does not depend on interpreting a stop phrase: the + * model either called the task_complete tool, or the last line is the literal + * TASK COMPLETE marker AGENTS.md requires. Only these may latch without a + * self-check; every text heuristic stays soft and re-opens after + * COMPLETION_LATCH_TTL_MS. */ +function isHardCompletionText(text: string): boolean { + const tail = + stripBoundaryMarkers(text) + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .pop() ?? "" + return /\btask\s+complete(d)?\b[.!]*$/i.test(tail) +} + /** * True when a done-claim already carries a concrete work report. * @@ -2292,6 +2325,8 @@ export default define({ intentNudgeAttempts: 0, completionSignaled: false, completionSignaledAt: 0, + completionHard: false, + latchReopened: false, emptyIdleNudged: false, todos: [], todosFetchedAt: 0, @@ -2522,6 +2557,27 @@ export default define({ * A `permission.asked` with no matching `permission.replied` would otherwise * stand the session down forever. Mirrors the compaction stale-guard. */ + /** A SOFT completion latch (stop phrase / celebration / done claim inferred + * from text) expires after COMPLETION_LATCH_TTL_MS with no genuine user + * message: it re-opens ONCE so a working session gets a single self-check + * nudge instead of sitting silent until the human types (ses_e72df2be, 2026-10-30). + * HARD latches (task_complete tool, literal TASK COMPLETE) never expire, and + * a latch that already re-opened once stays shut — no repeating nudges. */ + function softLatchExpired(sid: string, w: SessionWatch, now: number): boolean { + if (!w.completionSignaled) return false + if (w.completionHard || w.latchReopened) return false + if (!w.completionSignaledAt) return false + if (now - w.completionSignaledAt < COMPLETION_LATCH_TTL_MS) return false + w.latchReopened = true + w.completionSignaled = false + w.completionSignaledAt = 0 + log( + "warn", + `${short(sid)} soft completion latch older than ${COMPLETION_LATCH_TTL_MS / 60000}m with no user message — re-opening once for a self-check`, + ) + return true + } + function clearStalePermissionFlag(sid: string, w: SessionWatch) { if (!w.permissionPending) return if (w.permissionPendingAt && Date.now() - w.permissionPendingAt > PERMISSION_STALE_TTL_MS) { @@ -4553,6 +4609,7 @@ export default define({ if (w.completionSignaled) { w.completionSignaled = false w.completionSignaledAt = 0 + w.latchReopened = false dbg(`${short(sid)} genuine new user message — re-opening latched completion`) } // A genuine user message is a new episode for the stall ladder too @@ -5044,6 +5101,7 @@ export default define({ w.completionSignaled = true w.completionSignaledAt = Date.now() + w.completionHard = true log("info", `${short(sid)} task_complete called, ${(await isSubAgentSession(sid)) ? "subagent" : "agent"} done`) w.taskCompleteSignals++ if (w.taskCompleteSignals === 2) return { content: TASK_COMPLETE_REPEAT_WARNING } @@ -5105,6 +5163,7 @@ export default define({ } w.completionSignaled = true w.completionSignaledAt = Date.now() + w.completionHard = true w.taskCompleteSignals = 0 w.taskCompleteOverrides = 0 log("info", `${short(sid)} task_complete called, ${(await isSubAgentSession(sid)) ? "subagent" : "agent"} done`) @@ -5204,6 +5263,7 @@ export default define({ if (open.length === 0 && !w.completionSignaled) { w.completionSignaled = true w.completionSignaledAt = Date.now() + w.completionHard = isHardCompletionText(tailText) log("info", `${short(sid)} turn ends with a completion claim inside the activity window — latching completion, standing down`) } } else { @@ -5268,6 +5328,7 @@ export default define({ if (!w.completionSignaled) { w.completionSignaled = true w.completionSignaledAt = Date.now() + w.completionHard = false log("info", `${short(sid)} turn ends with a celebration and no open todos — latching completion, not nudging`) } else { dbg(`${short(sid)} completion already latched — skipping`) @@ -5316,6 +5377,7 @@ export default define({ if (!w.completionSignaled) { w.completionSignaled = true w.completionSignaledAt = Date.now() + w.completionHard = isHardCompletionText(text) log("info", `${short(sid)} turn ends with an explicit stop and no open todos — latching completion, not nudging`) } else { dbg(`${short(sid)} completion already latched — skipping`) @@ -5947,7 +6009,10 @@ export default define({ if (w.lastGenuineUserAt > w.completionSignaledAt) { w.completionSignaled = false w.completionSignaledAt = 0 + w.latchReopened = false dbg(`${short(sid)} genuine new user message after latched completion — re-opening stall detection`) + } else if (softLatchExpired(sid, w, now)) { + dbg(`${short(sid)} soft completion latch expired — re-opening for one self-check`) } else { dbg(`${short(sid)} completion latched — skipping stall detection`) } @@ -6043,7 +6108,10 @@ export default define({ if (w.lastGenuineUserAt > w.completionSignaledAt) { w.completionSignaled = false w.completionSignaledAt = 0 + w.latchReopened = false dbg(`${short(sid)} genuine new user message after latched completion — re-opening stall detection`) + } else if (softLatchExpired(sid, w, now)) { + dbg(`${short(sid)} soft completion latch expired — re-opening for one self-check`) } else { dbg(`${short(sid)} completion latched — skipping stall detection`) continue From 1c33652edeba90e701e4c6e72b42dc63e97fe105 Mon Sep 17 00:00:00 2001 From: famewolf Date: Fri, 30 Oct 2026 17:53:05 -0400 Subject: [PATCH 2/4] test(v2): pin soft-latch TTL behaviour, make it configurable - completionLatchTtlMs option (default 5m) so the TTL is testable and tunable; added to RECOGNISED_OPTIONS so the startup warning stays honest. - softLatchExpired sets pendingRecoveryArmed: the delayed inject drops an idle session's nudge unless a recovery is armed, so a re-opened latch detected the stall every tick and delivered nothing. - new test file: soft latch re-opens and nudges, is one-shot, and a literal trailing TASK COMPLETE never re-opens. --- src/v2/index.completion-latch-ttl.test.ts | 182 ++++++++++++++++++++++ src/v2/index.ts | 17 +- 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 src/v2/index.completion-latch-ttl.test.ts diff --git a/src/v2/index.completion-latch-ttl.test.ts b/src/v2/index.completion-latch-ttl.test.ts new file mode 100644 index 0000000..b6b7d96 --- /dev/null +++ b/src/v2/index.completion-latch-ttl.test.ts @@ -0,0 +1,182 @@ +/** + * Regression: a SOFT completion latch must not outlive the work. + * + * ses_e72df2be9ffe (2026-10-30): a turn ended with a stop phrase while its todo + * list read zero-open at that instant (a todo-set race, the work was not done). + * The text heuristic latched completion and the only thing that could clear it + * was a genuine user message — so the session sat silent for 20+ minutes while + * the model kept working, and the "stand-down" needed a human to un-latch it. + * + * The split this pins: + * HARD latch — task_complete tool, or a literal trailing `TASK COMPLETE`. + * Never expires; a session that really finished stays quiet. + * SOFT latch — stop phrase / done claim / celebration inferred from prose. + * Re-opens ONCE after `completionLatchTtlMs` with no genuine + * user message, so it gets a single self-check instead of + * silence. `latchReopened` makes it one-shot: not a nag loop. + */ +import { describe, test, expect, setDefaultTimeout } from "bun:test" +import plugin from "./index" + +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)) +setDefaultTimeout(45_000) + +const LOG_SALT = "IndexCompletionLatchTtlTest" +const SID = "ses_latch_ttl" +/** Short enough to observe in a test, long enough that latching still happens first. */ +const TTL_MS = 300 + + +function makeEventStream() { + const queue: any[] = [] + const waiters: ((ev: any) => void)[] = [] + let closed = false + const stream = { + push(ev: any) { + if (waiters.length) waiters.shift()!(ev) + else queue.push(ev) + }, + close() { + closed = true + while (waiters.length) waiters.shift()!(null) + }, + subscribe: () => stream, + [Symbol.asyncIterator]() { + return { + next: () => + new Promise((resolve) => { + if (queue.length) return resolve({ value: queue.shift(), done: false }) + if (closed) return resolve({ value: undefined, done: true }) + waiters.push((ev) => + resolve(ev === null ? { value: undefined, done: true } : { value: ev, done: false }), + ) + }), + return: () => { + closed = true + return Promise.resolve({ value: undefined, done: true }) + }, + } + }, + } + return stream +} + +const ev = (type: string, sid: string, data: Record = {}) => ({ + type, + data: { sessionID: sid, ...data }, +}) + +const TOOLS = [{ id: "read" }, { id: "write" }, { id: "shell" }, { id: "task_complete" }, { id: "compress" }] + +async function setup(history: unknown[], opts: Record = {}): Promise { + const injected: Array<{ text?: string }> = [] + const stream = makeEventStream() + const ctx: any = { + // Upstream's version guard: the v2 bundle aborts setup when the host does + // not expose `app.version` (it assumes a v1 server). + app: { name: "opencode", version: "2.0.27", channel: "stable" }, + event: stream, + options: { + registryScope: LOG_SALT, + // Small so a re-opened latch turns into a visible self-check: after + // `completionLatchTtlMs` the stall gate must actually fire, not just + // clear the flag and wait out a 10-minute silence window. + chunkTimeoutMs: 500, + toolTextCheckDelayMs: 0, + checkIntervalMs: 20, + gracePeriodMs: 0, + warmupMs: 0, + baseBackoffMs: 1, + maxBackoffMs: 2, + maxRetries: 1, + loopMaxContinues: 1, + injectIntervalMs: 0, + subagentWaitMs: 40, + completionLatchTtlMs: TTL_MS, + debug: true, + ...opts, + }, + session: { + context: async () => history, + active: async () => ({}), + interrupt: async () => ({}), + synthetic: async (a: any) => (injected.push({ text: a?.text }), {}), + prompt: async (a: any) => (injected.push({ text: a?.text }), {}), + }, + storage: { get: async () => ({ todos: [] }), set: async () => {}, remove: async () => {} }, + tool: { + transform: async (cb: any) => (cb({ add: () => {} }), { dispose() {} }), + list: async () => TOOLS, + }, + } + + const cleanup = await (plugin as any).setup(ctx) + await wait(60) + return { injected, cleanup, streamRef: stream } +} + +async function teardown(h: any) { + ;(h.cleanup as (() => void) | undefined)?.() +} + +const OLD = Date.now() - 3600_000 + +const userMessage = (text: string, at: number) => ({ + type: "user", + id: `msg_u_${at}`, + time: { created: at }, + content: [{ type: "text", text }], +}) +const assistantWith = (at: number, text: string) => ({ + type: "assistant", + id: `msg_a_${at}`, + time: { created: at }, + content: [{ type: "text", text }], +}) + +async function goIdle(h: any, settleMs = 400) { + for (const e of ["session.execution.started", "session.step.started", "session.step.ended", "session.idle"]) { + h.streamRef.push(ev(e, SID)) + await wait(10) + } + await wait(settleMs) +} + +const continues = (h: any) => h.injected.filter((i: any) => /continue/i.test(i.text ?? "")) + +describe("v2: completion latch TTL — soft re-opens once, hard never", () => { + test("a soft latch (stop phrase) re-opens after the TTL and draws a self-check", async () => { + const ack = "All set. Nothing to do." + const h = await setup([userMessage("go", OLD), assistantWith(OLD + 1000, ack)]) + await goIdle(h) + // Latched: nothing yet, the TTL has not elapsed. + expect(continues(h)).toHaveLength(0) + await wait(TTL_MS + 600) + const said = continues(h) + await teardown(h) + expect(said.length).toBeGreaterThan(0) + }) + + test("the re-open is one-shot — it does not keep nudging", async () => { + const ack = "Nothing pending from me." + const h = await setup([userMessage("go", OLD), assistantWith(OLD + 1000, ack)]) + await goIdle(h) + await wait(TTL_MS + 600) + const afterFirst = continues(h).length + expect(afterFirst).toBeGreaterThan(0) + await wait(TTL_MS * 4) + const afterSecond = continues(h).length + await teardown(h) + expect(afterSecond).toBe(afterFirst) + }) + + test("CONTROL: a hard latch (literal trailing TASK COMPLETE) never re-opens", async () => { + const ack = ["Shipped it, verified the log line.", "", "TASK COMPLETE"].join("\n") + const h = await setup([userMessage("go", OLD), assistantWith(OLD + 1000, ack)]) + await goIdle(h) + await wait(TTL_MS + 1200) + const said = continues(h) + await teardown(h) + expect(said).toHaveLength(0) + }) +}) diff --git a/src/v2/index.ts b/src/v2/index.ts index 824c13d..e3c2178 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -448,6 +448,11 @@ export interface AutoResumeOptions { chunkTimeoutMs?: number gracePeriodMs?: number stuckToolAgeMs?: number + /** How long a SOFT completion latch (a stop phrase, done claim or celebration + * inferred from text) survives without a genuine user message before it + * re-opens once for a self-check. A HARD latch (task_complete tool, literal + * TASK COMPLETE tail) never expires. Default 5 min; tests set it low. */ + completionLatchTtlMs?: number /** Tool names the busy-stall path never interrupts (case-insensitive). * Default `["compress"]`: every tool gets the young-tool deferral, and * `compress` is additionally never interrupted while fresh; @@ -2008,6 +2013,7 @@ export default define({ const checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS const gracePeriodMs = opts.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS const stuckToolAgeMs = opts.stuckToolAgeMs ?? DEFAULT_STUCK_TOOL_AGE_MS + const completionLatchTtlMs = opts.completionLatchTtlMs ?? COMPLETION_LATCH_TTL_MS const neverInterruptTools = (opts.neverInterruptTools ?? ["compress"]).map((n) => String(n).toLowerCase()) // `maxRecoveryRetries` is v1's name for the same knob. Prefer the v2 name, // fall back to the v1 alias so an existing v1 config ports unchanged. @@ -2118,6 +2124,7 @@ export default define({ // looking like a bug. (Mte90/opencode-auto-resume#33) const RECOGNISED_OPTIONS = new Set([ "chunkTimeoutMs", + "completionLatchTtlMs", "checkIntervalMs", "gracePeriodMs", "maxRetries", @@ -2567,13 +2574,19 @@ export default define({ if (!w.completionSignaled) return false if (w.completionHard || w.latchReopened) return false if (!w.completionSignaledAt) return false - if (now - w.completionSignaledAt < COMPLETION_LATCH_TTL_MS) return false + if (now - w.completionSignaledAt < completionLatchTtlMs) return false w.latchReopened = true w.completionSignaled = false w.completionSignaledAt = 0 + // The re-opened session must be allowed to receive the self-check: the + // injected nudge goes through a delayed timer whose idle-state guard + // drops the injection unless a recovery is armed (the same flag the + // failed-turn path sets before recover()). Without it the detection + // re-arms every tick and nothing is ever delivered. + w.pendingRecoveryArmed = true log( "warn", - `${short(sid)} soft completion latch older than ${COMPLETION_LATCH_TTL_MS / 60000}m with no user message — re-opening once for a self-check`, + `${short(sid)} soft completion latch older than ${Math.round(completionLatchTtlMs / 1000)}s with no user message — re-opening once for a self-check`, ) return true } From 5194a68ef8e38e0e7ad468aad4a4255e56c1c04b Mon Sep 17 00:00:00 2001 From: famewolf Date: Sat, 31 Oct 2026 08:57:12 -0400 Subject: [PATCH 3/4] fix(v2): own-prompt detection in execution.started (prevent false counter reset on own recovery turns) --- src/v2/index.ts | 43 +++++++++++++++++++------------------------ 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index e3c2178..a0d9d68 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -567,12 +567,6 @@ export interface AutoResumeOptions { readyToContinuePatterns?: string[] /** Prompt sent when a done-claim arrives with no work to show. */ doneWithoutDetailsPrompt?: string - /** - * Per-attempt cooldowns (ms) before a rate-limited session may be retried. - * Gates, not timers: evaluated on each failure and each watchdog tick, so - * nothing is lost to a reload. Past the ladder the session stays silent - * until a genuine user turn. Default spans ~12h for overnight coverage. - */ /** * Per-attempt cooldowns (ms) before a rate-limited session may be retried. * Gates, not timers: evaluated on each failure and each watchdog tick, so @@ -588,10 +582,6 @@ export interface AutoResumeOptions { // Logging // --------------------------------------------------------------------------- -// --------------------------------------------------------------------------- -// Logging -// --------------------------------------------------------------------------- - /** * v2 logging goes to the process console only. * @@ -818,7 +808,7 @@ const DONE_CLAIM_PATTERN_SOURCES = [ "\\bnothing\\s+(?:else\\s+)?(?:left|remaining|to do)", ] -const DEFAULT_DONE_CLAIM_PATTERNS = [...DONE_CLAIM_PATTERN_SOURCES] + const DEFAULT_READY_TO_CONTINUE_PATTERNS = [ "ready to continue with task", @@ -1640,9 +1630,6 @@ function isUserHandoff(text: string): boolean { return patterns.some((p) => p.test(last)) } -function backoffMs(attempt: number, base: number, max: number): number { - return Math.min(base * Math.pow(2, attempt - 1), max) -} /** Silence-watchdog recover reasons look like "no activity for 186s". Shared * by the stall-text builder (idle-field dedup) and recover (episode reset): @@ -2097,7 +2084,7 @@ export default define({ return out } - const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DEFAULT_DONE_CLAIM_PATTERNS, "im") + const doneClaimPatterns = compilePatterns(opts.doneClaimPatterns, DONE_CLAIM_PATTERN_SOURCES, "im") const readyToContinuePatterns = compilePatterns( opts.readyToContinuePatterns, DEFAULT_READY_TO_CONTINUE_PATTERNS, @@ -3639,7 +3626,7 @@ export default define({ } w.recovering = true const attempt = providerFault ? Math.max(1, w.resumeAttempts) : w.resumeAttempts + 1 - const delay = backoffMs(attempt, baseBackoff, maxBackoff) + const delay = Math.min(baseBackoff * 2 ** (attempt - 1), maxBackoff) if (providerFault) { log( "info", @@ -6857,14 +6844,22 @@ export default define({ let ownRung = !providerRetryFresh && selfInjectFresh if (ownRung) { try { - for (const m of await loadMessages(sid)) { - if ( - msgRole(m) === "user" && - msgAt(m) > w.lastInjectAt - ) { - ownRung = false - break - } + const hist = await loadMessages(sid) + for (const m of hist) { + if (msgRole(m) !== "user") continue + if (msgAt(m) <= w.lastInjectAt) continue + // Exclude our own prompts — they land + // shortly after the inject and must not + // be treated as a genuine user turn + // (which would reset the counter). + const text = messageTextOf(m) + const ownByText = typeof text === "string" && text.length > 0 && w.ownPromptTexts.includes(text) + const ownByShape = looksLikeOwnPrompt(text) + const ownByTime = msgAt(m) >= w.lastInjectAt && msgAt(m) - w.lastInjectAt < OWN_PROMPT_TIME_MS + const ownByTimeOnly = ownByTime && !(typeof text === "string" && text.length > 0) + if (ownByText || ownByShape || ownByTimeOnly) continue + ownRung = false + break } } catch { // Fail closed: if the history cannot be read From fa69c38c3ed4aa3bc7c2c14f5e243b853f27355b Mon Sep 17 00:00:00 2001 From: famewolf Date: Sat, 31 Oct 2026 09:05:41 -0400 Subject: [PATCH 4/4] refactor(v2): shrink over-engineering (quota guard dedup, isHardCompletionText inline, readPausedSet cache, softLatchExpired comment, FEATURE_GATED_OPTIONS comment, isRateLimitReason inline) --- src/v2/index.ts | 147 ++++++++++++++---------------------------------- 1 file changed, 42 insertions(+), 105 deletions(-) diff --git a/src/v2/index.ts b/src/v2/index.ts index a0d9d68..a2ccda2 100644 --- a/src/v2/index.ts +++ b/src/v2/index.ts @@ -207,9 +207,6 @@ interface SessionWatch { * explanation reads as broken (ses_edd3d940, 2026-10-10). */ quotaNoticedAt: number gaveUp: boolean - /** Timestamp of the last quota notice sent (dedupes back-to-back duplicates - * from concurrent event paths; 100ms window). */ - lastQuotaNoticeAt: number /** User-paused: auto-resume will not inject or arm quota for this session * until explicitly resumed. Persisted via the marker file + storage. */ paused: boolean @@ -1357,20 +1354,7 @@ function turnEndsWithCompletionClaim(text: string): boolean { return endsWithCelebration(text) || containsStopStatement(text) } -/** A completion signal that does not depend on interpreting a stop phrase: the - * model either called the task_complete tool, or the last line is the literal - * TASK COMPLETE marker AGENTS.md requires. Only these may latch without a - * self-check; every text heuristic stays soft and re-opens after - * COMPLETION_LATCH_TTL_MS. */ -function isHardCompletionText(text: string): boolean { - const tail = - stripBoundaryMarkers(text) - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0) - .pop() ?? "" - return /\btask\s+complete(d)?\b[.!]*$/i.test(tail) -} + /** * True when a done-claim already carries a concrete work report. @@ -2097,13 +2081,7 @@ export default define({ ) // Options accepted but not yet acted on, because the v2 feature they tune - // is not ported. Listed (not warned) so an existing v1 config stays valid - // and the gap is documented rather than surprising. - // - // Empty: every v1 option this build understands is now applied. Kept as a - // list rather than deleted because the accepted-but-inert= warning is the - // only thing a user has to go on when an option is quietly ignored — so - // the next one that arrives has somewhere to be listed. + // is not ported. Listed (not warned) so an existing v1 config stays valid. const FEATURE_GATED_OPTIONS = [] as const // Options this build understands. Anything else in the user's config is @@ -2285,7 +2263,6 @@ export default define({ providerRetryAt: 0, quotaNoticedAt: 0, gaveUp: false, - lastQuotaNoticeAt: 0, paused: false, lastGenuineUserAt: 0, consecFailureRecovers: 0, @@ -2386,25 +2363,17 @@ export default define({ // ---- Pause / resume (user-controlled) --------------------------------- // Source of truth: a marker file listing paused session ids (one per line) // or "*" for all. Written by the CLI helper and the /auto-resume command. - let pausedCacheMtime = 0 - let pausedCacheSet: Set = new Set() function readPausedSet(): Set { try { - const st = statSync(PAUSED_MARKER_FILE) - if (st.mtimeMs === pausedCacheMtime) return pausedCacheSet - pausedCacheMtime = st.mtimeMs const raw = readFileSync(PAUSED_MARKER_FILE, "utf8") const s = new Set() for (const line of raw.split(/\r?\n/)) { const t = line.trim() if (t) s.add(t) } - pausedCacheSet = s return s } catch { - pausedCacheMtime = 0 - pausedCacheSet = new Set() - return pausedCacheSet + return new Set() } } function isPaused(sid: string): boolean { @@ -2420,7 +2389,6 @@ export default define({ } catch {} try { writeFileSync(PAUSED_MARKER_FILE, [...s].join("\n") + (s.size ? "\n" : ""), "utf8") - pausedCacheMtime = 0 } catch (e) { dbg(`${short(sid)} pause write failed: ${e instanceof Error ? e.message : String(e)}`) } @@ -2565,12 +2533,7 @@ export default define({ w.latchReopened = true w.completionSignaled = false w.completionSignaledAt = 0 - // The re-opened session must be allowed to receive the self-check: the - // injected nudge goes through a delayed timer whose idle-state guard - // drops the injection unless a recovery is armed (the same flag the - // failed-turn path sets before recover()). Without it the detection - // re-arms every tick and nothing is ever delivered. - w.pendingRecoveryArmed = true + w.pendingRecoveryArmed = true // arms the delayed timer so self-check delivers log( "warn", `${short(sid)} soft completion latch older than ${Math.round(completionLatchTtlMs / 1000)}s with no user message — re-opening once for a self-check`, @@ -2692,6 +2655,36 @@ export default define({ void recover(sid, `rate-limit cooldown served (rung ${rung + 1}/${ladder.length}, ${cooldownMin}m)`) } + /** Shared quota guard: returns true if the session is rate-limited (caller should skip recovery). */ + function quotaGuard(sid: string, w: SessionWatch, messages: unknown[]): boolean { + try { + const last = messages.length ? messages[messages.length - 1] : null + let qtype = "" + let qmsg = "" + if (last && msgRole(last) === "assistant") { + const qe = (last as { error?: unknown; info?: { error?: unknown } }).error ?? (last as { info?: { error?: unknown } }).info?.error + if (typeof qe === "string") qmsg = qe + else if (qe && typeof qe === "object") { + qtype = String((qe as { name?: unknown; type?: unknown }).name ?? (qe as { type?: unknown }).type ?? "") + qmsg = String((qe as { message?: unknown }).message ?? "") + } + const qtxt = lastAssistantTextFrom(messages) + if (qtxt) qmsg = qmsg ? `${qmsg} ${qtxt}` : qtxt + } + if (isRateLimitError(qtype, qmsg)) { + handleRateLimitFailure(sid, w, qtype, qmsg) + return true + } + } catch (e) { + dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`) + } + if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) { + dbg(`${short(sid)} quota-armed — skipping stall recover`) + return true + } + return false + } + function handleRateLimitFailure(sid: string, w: SessionWatch, errType: string, errMsg: string): void { if (isPaused(sid)) { dbg(`${short(sid)} paused — not arming quota ladder`) @@ -2704,9 +2697,8 @@ export default define({ if (n >= ladder.length) { w.awaitingQuotaRetry = false log("warn", `${short(sid)} ${tag} budget exhausted (${ladder.length} attempts) — standing down until user turn`) - if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) { + if (!w.quotaNoticedAt) { w.quotaNoticedAt = Date.now() - w.lastQuotaNoticeAt = Date.now() void sendQuotaNotice(sid, `Rate limited — standing down until you return (budget exhausted after ${ladder.length} attempts).`) } return @@ -2728,9 +2720,8 @@ export default define({ // User-visible twin of the stand-down: silence reads as dead // (ses_edd3d940). One notice per episode (first arm only); // resume:false so it never kicks a turn or re-arms budgets. - if (!w.quotaNoticedAt && Date.now() - w.lastQuotaNoticeAt > 100) { + if (!w.quotaNoticedAt) { w.quotaNoticedAt = Date.now() - w.lastQuotaNoticeAt = Date.now() const mins = Math.max(1, Math.round(waitMs / 60000)) const when = mins >= 60 ? `~${Math.floor(mins / 60)}h ${mins % 60}m` : `~${mins}m` void sendQuotaNotice(sid, `Rate limited, retrying in ${when} (attempt ${n + 1}/${ladder.length}).`) @@ -3469,7 +3460,6 @@ export default define({ const elapsed = w.lastActivityAt ? Math.round((Date.now() - w.lastActivityAt) / 1000) : 0 const hms = (n: number) => n < 60 ? `${n}s` : n < 3600 ? `${Math.floor(n / 60)}m ${n % 60}s` : `${Math.floor(n / 3600)}h ${Math.floor((n % 3600) / 60)}m` - const isRateLimitReason = /rate-limit/i.test(reason) // Each fact exactly once: a "no activity for Ns" reason already states // the silence, so adding "idle M:SS" repeats it (188s vs 3m 9s). And // idle is only news for silence stalls: a dead stream is seconds old @@ -3497,7 +3487,7 @@ export default define({ const retries = prospectiveRetries ?? w.workRetries const head = [ isSilence ? null : reason, - isRateLimitReason ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`, + /rate-limit/i.test(reason) ? null : `attempt ${Math.min(shown, maxRetries)}/${maxRetries} (retry #${retries})`, isSilence ? `idle ${hms(elapsed)}` : null, now, ] @@ -5263,7 +5253,7 @@ export default define({ if (open.length === 0 && !w.completionSignaled) { w.completionSignaled = true w.completionSignaledAt = Date.now() - w.completionHard = isHardCompletionText(tailText) + w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(tailText).split("\n").map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "") log("info", `${short(sid)} turn ends with a completion claim inside the activity window — latching completion, standing down`) } } else { @@ -5377,7 +5367,7 @@ export default define({ if (!w.completionSignaled) { w.completionSignaled = true w.completionSignaledAt = Date.now() - w.completionHard = isHardCompletionText(text) + w.completionHard = /\btask\s+complete(d)?\b[.!]*$/i.test(stripBoundaryMarkers(text).split("\n").map((l) => l.trim()).filter((l) => l.length > 0).pop() ?? "") log("info", `${short(sid)} turn ends with an explicit stop and no open todos — latching completion, not nudging`) } else { dbg(`${short(sid)} completion already latched — skipping`) @@ -6038,33 +6028,7 @@ export default define({ ? Math.floor(Math.random() * STALL_JITTER_MS * 2) - STALL_JITTER_MS : 0 if (silence >= baseInterval + jitter) { - // Quota guard for idle sessions: don't inject a stall nudge - // when the last assistant message carries a quota/usage error. - try { - const _qlast2 = history.length ? history[history.length - 1] : null - let _qtype2 = "" - let _qmsg2 = "" - if (_qlast2 && msgRole(_qlast2) === "assistant") { - const _qe2 = (_qlast2 as { error?: unknown; info?: { error?: unknown } }).error ?? (_qlast2 as { info?: { error?: unknown } }).info?.error - if (typeof _qe2 === "string") _qmsg2 = _qe2 - else if (_qe2 && typeof _qe2 === "object") { - _qtype2 = String((_qe2 as { name?: unknown; type?: unknown }).name ?? (_qe2 as { type?: unknown }).type ?? "") - _qmsg2 = String((_qe2 as { message?: unknown }).message ?? "") - } - const _qtxt2 = lastAssistantTextFrom(history) - if (_qtxt2) _qmsg2 = _qmsg2 ? `${_qmsg2} ${_qtxt2}` : _qtxt2 - } - if (isRateLimitError(_qtype2, _qmsg2)) { - handleRateLimitFailure(sid, w, _qtype2, _qmsg2) - continue - } - } catch (e) { - dbg(`${short(sid)} quota guard (idle) failed: ${e instanceof Error ? e.message : String(e)}`) - } - if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) { - dbg(`${short(sid)} quota-armed — skipping stall recover`) - continue - } + if (quotaGuard(sid, w, history)) continue await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`) } } @@ -6286,35 +6250,8 @@ export default define({ // that even though the default policy refuses to. await tryAbortAndResume(sid, w, true) } else { - // Quota guard: if the last assistant message carries a quota/usage-limit - // error, treat it as a rate-limit failure (stand down + quota ladder) - // instead of injecting a stall-recovery prompt that will also fail. - try { - const _qm = await loadMessages(sid) - const _qlast = _qm.length ? _qm[_qm.length - 1] : null - let _qtype = "" - let _qmsg = "" - if (_qlast && msgRole(_qlast) === "assistant") { - const _qe = (_qlast as { error?: unknown; info?: { error?: unknown } }).error ?? (_qlast as { info?: { error?: unknown } }).info?.error - if (typeof _qe === "string") _qmsg = _qe - else if (_qe && typeof _qe === "object") { - _qtype = String((_qe as { name?: unknown; type?: unknown }).name ?? (_qe as { type?: unknown }).type ?? "") - _qmsg = String((_qe as { message?: unknown }).message ?? "") - } - const _qtxt = lastAssistantTextFrom(_qm) - if (_qtxt) _qmsg = _qmsg ? `${_qmsg} ${_qtxt}` : _qtxt - } - if (isRateLimitError(_qtype, _qmsg)) { - handleRateLimitFailure(sid, w, _qtype, _qmsg) - continue - } - } catch (e) { - dbg(`${short(sid)} quota guard failed: ${e instanceof Error ? e.message : String(e)}`) - } - if (w.awaitingQuotaRetry || w.rateLimitedAt > 0) { - dbg(`${short(sid)} quota-armed — skipping stall recover`) - continue - } + const _qm = await loadMessages(sid) + if (quotaGuard(sid, w, _qm)) continue await recover(sid, `no activity for ${Math.ceil(silence / 1000)}s`) } }