diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..42703aae757c 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -102,24 +102,32 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; - const runtime = shell.runtime ?? shell.session ?? null; const latestRun = shell.latestRun ?? shell.latestTurn ?? null; - // Only a FRESH failure raises the hand: a thread snoozed while already + // Only a FRESH run outcome raises the hand: a thread snoozed while already // failed stays snoozed — that snooze was the user saying "I saw it, not - // now". session.updatedAt stamps the status edge, so an error newer than - // the snooze is new information. + // now". The run's stable completion timestamp is the signal. Never use + // runtime.updatedAt here: in production shells it is projection activity + // time and advances on unrelated events (title updates, metadata changes), + // which used to wake snoozed limited threads long before their reset time. if ( - (runtime?.status === "error" || runtime?.status === "failed") && - (shell.snoozedAt == null || - (runtime.updatedAt != null && Date.parse(runtime.updatedAt) > Date.parse(shell.snoozedAt))) + shell.snoozedAt != null && + (latestRun?.state === "completed" || + latestRun?.status === "completed" || + latestRun?.state === "failed" || + latestRun?.status === "failed") && + latestRun.completedAt != null && + Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt) ) { return true; } + // Legacy session errors without a run timestamp: session.updatedAt stamps + // the status edge, so an error newer than the snooze is new information. + const session = shell.session ?? null; if ( - shell.snoozedAt != null && - (latestRun?.state === "completed" || latestRun?.status === "completed") && - latestRun.completedAt != null && - Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt) + session !== null && + (session.status === "error" || session.status === "failed") && + (shell.snoozedAt == null || + (session.updatedAt != null && Date.parse(session.updatedAt) > Date.parse(shell.snoozedAt))) ) { return true; } @@ -198,7 +206,10 @@ export function threadWokeAt( const runtime = shell.runtime ?? shell.session ?? null; if ( shell.snoozedAt != null && - (latestRun?.state === "completed" || latestRun?.status === "completed") && + (latestRun?.state === "completed" || + latestRun?.status === "completed" || + latestRun?.state === "failed" || + latestRun?.status === "failed") && latestRun.completedAt != null && Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt) ) { diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a95..e38d07dd2648 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -168,6 +168,63 @@ describe("threadRaisedHandWhileSnoozed", () => { }); }); +describe("effectiveSnoozed with production runtime shells", () => { + function productionFailedShell(overrides: { + readonly runtimeUpdatedAt: string; + readonly runCompletedAt: string | null; + }): ThreadSnoozeShell { + return { + snoozedUntil: FUTURE_WAKE, + // Snoozed at 09:00, after the usage-limit failure. + snoozedAt: SNOOZED_AT, + hasPendingApprovals: false, + hasPendingUserInput: false, + runtime: { + threadId: ThreadId.make("thread-1"), + status: "failed", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: "boom", + updatedAt: overrides.runtimeUpdatedAt, + }, + latestRun: { + turnId: TurnId.make("turn-1"), + status: "failed", + requestedAt: "2026-04-10T07:00:00.000Z", + startedAt: null, + completedAt: overrides.runCompletedAt, + }, + }; + } + + it("stays snoozed when unrelated activity bumps runtime.updatedAt after a limit snooze", () => { + // A usage-limited thread snoozed until reset: failure at 08:00, snoozed at + // 09:00. A later title/metadata update bumps runtime.updatedAt + // (projection activity time) to 11:00 without any new failure. The thread + // must stay snoozed until the reset time. + expect( + effectiveSnoozed( + productionFailedShell({ + runtimeUpdatedAt: "2026-04-10T11:00:00.000Z", + runCompletedAt: "2026-04-10T08:00:00.000Z", + }), + { now: NOW }, + ), + ).toBe(true); + }); + + it("wakes when a run fails after the snooze was set", () => { + const shell = productionFailedShell({ + runtimeUpdatedAt: "2026-04-10T11:00:00.000Z", + runCompletedAt: "2026-04-10T10:30:00.000Z", + }); + expect(effectiveSnoozed(shell, { now: NOW })).toBe(false); + expect(threadRaisedHandWhileSnoozed(shell)).toBe(true); + expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T10:30:00.000Z"); + }); +}); + describe("canSnooze", () => { it("allows snoozing quiet and working threads alike", () => { expect(canSnooze({ ...makeShell({}), latestUserMessageAt: null }, { now: NOW })).toBe(true);