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
57 changes: 33 additions & 24 deletions packages/client-runtime/src/state/threadSettled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,23 +82,41 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean
if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
if (shell.pendingSubagentRequest != null) return true;
const runtime = shell.runtime ?? shell.session ?? null;
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
if (
(runtime?.status === "error" || runtime?.status === "failed") &&
(shell.snoozedAt == null ||
(runtime.updatedAt != null && Date.parse(runtime.updatedAt) > Date.parse(shell.snoozedAt)))
) {
// Only a FRESH failure raises the hand: a thread snoozed while already
// failed stays snoozed — that snooze was the user saying "I saw it, not
// now".
if ((runtime?.status === "error" || runtime?.status === "failed") && shell.snoozedAt == null) {
return true;
}
if (
if (snoozeWakingRunEndedAt(shell) !== null) return true;
// Legacy session-shaped shells stamp the status edge on session.updatedAt,
// so an error newer than the snooze is new information. Runtime shells
// carry projection activity time there instead, which any later event
// (a rename, title regeneration) advances without a new failure.
return (
shell.runtime == null &&
shell.session?.status === "error" &&
shell.session.updatedAt != null &&
shell.snoozedAt != null &&
(latestRun?.state === "completed" || latestRun?.status === "completed") &&
latestRun.completedAt != null &&
Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt)
) {
return true;
}
return false;
Date.parse(shell.session.updatedAt) > Date.parse(shell.snoozedAt)
);
}

/**
* The latest run's end time when that run completed, or failed while the
* runtime is failed, strictly after the snooze was set. Mirrors the server's
* auto-settlement wake rule, so equal timestamps stay parked.
*/
function snoozeWakingRunEndedAt(shell: ThreadSnoozeShell): string | null {
if (shell.snoozedAt == null) return null;
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
if (latestRun?.completedAt == null) return null;
const runCompleted = latestRun.state === "completed" || latestRun.status === "completed";
const runFailed = shell.runtime?.status === "failed" && latestRun.status === "failed";
if (!runCompleted && !runFailed) return null;
return Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt)
? latestRun.completedAt
: null;
}

export function canSnooze(
Expand Down Expand Up @@ -144,17 +162,8 @@ export function threadWokeAt(
// indicator the user already cleared by visiting (snoozedUntil is newer
// than that visit's lastVisitedAt).
if (threadRaisedHandWhileSnoozed(shell)) {
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
const runtime = shell.runtime ?? shell.session ?? null;
if (
shell.snoozedAt != null &&
(latestRun?.state === "completed" || latestRun?.status === "completed") &&
latestRun.completedAt != null &&
Date.parse(latestRun.completedAt) > Date.parse(shell.snoozedAt)
) {
return latestRun.completedAt;
}
return runtime?.updatedAt ?? shell.snoozedAt ?? null;
return snoozeWakingRunEndedAt(shell) ?? runtime?.updatedAt ?? shell.snoozedAt ?? null;
}
return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;
}
Expand Down
85 changes: 85 additions & 0 deletions packages/client-runtime/src/state/threadSnoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,28 @@ function makeShell(input: {
};
}

// Production shells carry runtime, not session: runtime.updatedAt is the
// projection's activity time, which any later event advances.
function makeFailedRuntimeShell(input: {
readonly snoozedAt?: string | null;
readonly runCompletedAt: string;
readonly runtimeUpdatedAt: string;
}): ThreadSnoozeShell {
return {
snoozedUntil: FUTURE_WAKE,
snoozedAt: input.snoozedAt === undefined ? SNOOZED_AT : input.snoozedAt,
hasPendingApprovals: false,
hasPendingUserInput: false,
runtime: { status: "failed", updatedAt: input.runtimeUpdatedAt },
latestRun: {
status: "failed",
requestedAt: "2026-04-10T08:00:00.000Z",
startedAt: "2026-04-10T08:00:00.000Z",
completedAt: input.runCompletedAt,
},
};
}

type QueuedTurnShell = Parameters<typeof hasQueuedTurnStart>[0];

function makeQueuedTurnShell(overrides: Partial<QueuedTurnShell> = {}): QueuedTurnShell {
Expand Down Expand Up @@ -119,6 +141,57 @@ describe("effectiveSnoozed", () => {
).toBe(true);
});

it("stays snoozed when only projection activity moves after a limit snooze", () => {
// Snoozed until the usage-limit reset at 9:00 after the 8:30 failure; a
// rename at 11:00 bumps runtime.updatedAt without a new failure.
expect(
effectiveSnoozed(
makeFailedRuntimeShell({
runCompletedAt: "2026-04-10T08:30:00.000Z",
runtimeUpdatedAt: "2026-04-10T11:00:00.000Z",
}),
{ now: NOW },
),
).toBe(true);
});

it("stays snoozed when the failed run ended at the snooze instant", () => {
expect(
effectiveSnoozed(
makeFailedRuntimeShell({
runCompletedAt: SNOOZED_AT,
runtimeUpdatedAt: "2026-04-10T11:00:00.000Z",
}),
{ now: NOW },
),
).toBe(true);
});

it("wakes early when a run fails after the snooze was set", () => {
expect(
effectiveSnoozed(
makeFailedRuntimeShell({
runCompletedAt: "2026-04-10T10:30:00.000Z",
runtimeUpdatedAt: "2026-04-10T11:00:00.000Z",
}),
{ now: NOW },
),
).toBe(false);
});

it("wakes a failed runtime snoozed without a snooze timestamp", () => {
expect(
effectiveSnoozed(
makeFailedRuntimeShell({
snoozedAt: null,
runCompletedAt: "2026-04-10T08:30:00.000Z",
runtimeUpdatedAt: "2026-04-10T08:30:00.000Z",
}),
{ now: NOW },
),
).toBe(false);
});

it("stays snoozed while the session keeps working — snooze never pauses the agent", () => {
expect(
effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), {
Expand Down Expand Up @@ -287,6 +360,18 @@ describe("threadWokeAt", () => {
).toBe("2026-04-10T11:00:00.000Z");
});

it("reports the failure time, not projection activity, for a fresh failure wake", () => {
expect(
threadWokeAt(
makeFailedRuntimeShell({
runCompletedAt: "2026-04-10T10:30:00.000Z",
runtimeUpdatedAt: "2026-04-10T11:00:00.000Z",
}),
{ now: NOW },
),
).toBe("2026-04-10T10:30:00.000Z");
});

it("keeps the early wake authoritative after the scheduled time passes", () => {
expect(
threadWokeAt(
Expand Down
Loading