Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High state/threadSettled.ts:125

A failed production shell with no snoozedAt returns false, so the thread stays hidden until snoozedUntil instead of waking immediately on failure. The fallback only reads shell.session, while production shells populate shell.runtime; include shell.runtime so failures without a completion watermark preserve the server's early-wake behavior.

-  const session = shell.session ?? null;
+  const session = shell.session ?? shell.runtime ?? null;
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/threadSettled.ts around line 125:

A failed production shell with no `snoozedAt` returns `false`, so the thread stays hidden until `snoozedUntil` instead of waking immediately on failure. The fallback only reads `shell.session`, while production shells populate `shell.runtime`; include `shell.runtime` so failures without a completion watermark preserve the server's early-wake behavior.

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;
}
Expand Down Expand Up @@ -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)
) {
Expand Down
57 changes: 57 additions & 0 deletions packages/client-runtime/src/state/threadSnoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading