Skip to content

Commit ae8ecf6

Browse files
fix(server): snoozed failed threads stay snoozed after background updates
Background pull request sync bumped a failed thread's persisted updatedAt, which clients read as a fresh failure and woke the thread early. Re-snoozing to the same wake time then kept the stale snoozedAt, so the thread stayed visible. Co-authored-by: Claude Opus 5.5 <[email protected]>
1 parent dff412c commit ae8ecf6

7 files changed

Lines changed: 153 additions & 17 deletions

File tree

‎apps/server/src/orchestration-v2/Orchestrator.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2773,13 +2773,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
27732773
thread.snoozedUntil != null &&
27742774
snoozedUntil !== null &&
27752775
DateTime.toEpochMillis(thread.snoozedUntil) === DateTime.toEpochMillis(snoozedUntil);
2776-
const existingSnoozedAt = sameWakeTime ? (thread.snoozedAt ?? null) : null;
27772776
return {
27782777
...thread,
27792778
snoozedUntil,
27802779
limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null,
2781-
snoozedAt: existingSnoozedAt ?? now,
2782-
updatedAt: existingSnoozedAt === null ? now : thread.updatedAt,
2780+
// Every snooze acknowledges what the user can see now. Clients wake
2781+
// a snoozed thread early on activity newer than snoozedAt, so
2782+
// keeping an older stamp on a same-time re-snooze would leave an
2783+
// early-woken thread visible after a "successful" snooze.
2784+
snoozedAt: now,
2785+
updatedAt: sameWakeTime ? thread.updatedAt : now,
27832786
};
27842787
}
27852788
case "thread.unsnooze": {

‎apps/server/src/orchestration-v2/ProjectionStore.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1458,7 +1458,7 @@ export function threadShellFromProjection(
14581458
itemCount: activeLocalTurnItems(projection).length,
14591459
visibleItemCount: projection.visibleTurnItems.length,
14601460
createdAt: projection.thread.createdAt,
1461-
updatedAt: projection.updatedAt,
1461+
updatedAt: projection.thread.updatedAt,
14621462
archivedAt: projection.thread.archivedAt,
14631463
settledOverride: projection.thread.settledOverride,
14641464
settledAt: projection.thread.settledAt,
@@ -2597,9 +2597,11 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
25972597
event.type !== "thread.pinned" &&
25982598
event.type !== "thread.unpinned" &&
25992599
event.type !== "thread.pin-reordered" &&
2600+
event.type !== "thread.active-reordered" &&
26002601
event.type !== "thread.visited" &&
26012602
event.type !== "thread.marked-unread" &&
26022603
event.type !== "thread.metadata-updated" &&
2604+
event.type !== "thread.pull-request-synced" &&
26032605
event.type !== "thread.runtime-mode-updated" &&
26042606
event.type !== "thread.interaction-mode-updated" &&
26052607
event.type !== "thread.model-selection-updated" &&

‎apps/server/src/orchestration-v2/runtimeLayer.test.ts‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4459,6 +4459,7 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
44594459
const firstUpdatedAt = firstProjection.thread.updatedAt;
44604460
assert.isNotNull(firstSnoozedAt);
44614461

4462+
yield* TestClock.adjust("1 minute");
44624463
yield* orchestrator.dispatch({
44634464
type: "thread.snooze",
44644465
commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-again"),
@@ -4470,7 +4471,11 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
44704471
const thread = shell.threads.find((candidate) => candidate.id === threadId);
44714472
assert.isDefined(thread);
44724473
assert.equal(DateTime.formatIso(thread.snoozedUntil!), snoozedUntil);
4473-
assert.deepEqual(thread.snoozedAt, firstSnoozedAt);
4474+
// A same-time re-snooze re-acknowledges the thread without counting as activity.
4475+
assert.isNotNull(thread.snoozedAt);
4476+
assert.isTrue(
4477+
DateTime.toEpochMillis(thread.snoozedAt!) > DateTime.toEpochMillis(firstSnoozedAt!),
4478+
);
44744479
assert.deepEqual(thread.updatedAt, firstUpdatedAt);
44754480

44764481
yield* orchestrator.dispatch({
@@ -4491,6 +4496,76 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
44914496
assert.isNull(awakened.thread.snoozedAt);
44924497
}),
44934498
);
4499+
4500+
// Clients treat a failed thread's updatedAt as its failure edge, so a
4501+
// background refresh bumping it would wake the thread from snooze.
4502+
it.effect("keeps background pull request sync out of the persisted updatedAt", () =>
4503+
Effect.gen(function* () {
4504+
const projects = yield* ProjectService.ProjectService;
4505+
const orchestrator = yield* Orchestrator.OrchestratorV2;
4506+
const projectId = ProjectId.make("runtime-layer-pr-sync-project");
4507+
const threadId = ThreadId.make("runtime-layer-pr-sync-thread");
4508+
const key = { host: "github.com", repository: "owner/repository", number: 42 };
4509+
4510+
yield* projects.create({
4511+
commandId: CommandId.make("runtime-layer-pr-sync-project-create"),
4512+
projectId,
4513+
title: "PR sync projection",
4514+
workspaceRoot: "/tmp/runtime-layer-pr-sync-project",
4515+
});
4516+
yield* orchestrator.dispatch({
4517+
type: "thread.create",
4518+
createdBy: "user",
4519+
creationSource: "web",
4520+
commandId: CommandId.make("runtime-layer-pr-sync-thread-create"),
4521+
threadId,
4522+
projectId,
4523+
title: "PR sync thread",
4524+
modelSelection,
4525+
runtimeMode: "full-access",
4526+
interactionMode: "default",
4527+
branch: null,
4528+
worktreePath: null,
4529+
});
4530+
yield* orchestrator.dispatch({
4531+
type: "thread.pull-request.link",
4532+
commandId: CommandId.make("runtime-layer-pr-sync-link"),
4533+
threadId,
4534+
...key,
4535+
url: "https://github.com/owner/repository/pull/42",
4536+
source: "manual",
4537+
});
4538+
const linkedShell = yield* orchestrator.getShellSnapshot();
4539+
const linkedUpdatedAt = linkedShell.threads.find((t) => t.id === threadId)?.updatedAt;
4540+
assert.isDefined(linkedUpdatedAt);
4541+
4542+
yield* TestClock.adjust("1 minute");
4543+
yield* orchestrator.dispatch({
4544+
type: "thread.pull-request-link.sync",
4545+
commandId: CommandId.make("runtime-layer-pr-sync-sync"),
4546+
threadId,
4547+
...key,
4548+
snapshot: {
4549+
state: "open",
4550+
title: "Ship it",
4551+
headBranch: "feature",
4552+
baseBranch: "main",
4553+
isDraft: false,
4554+
updatedAt: "2026-08-27T00:00:00.000Z",
4555+
syncedAt: "2026-08-27T00:00:00.000Z",
4556+
closedAt: null,
4557+
mergedAt: null,
4558+
},
4559+
stack: null,
4560+
});
4561+
4562+
const shell = yield* orchestrator.getShellSnapshot();
4563+
const thread = shell.threads.find((candidate) => candidate.id === threadId);
4564+
assert.isDefined(thread);
4565+
assert.equal(thread.pullRequests?.[0]?.snapshot?.title, "Ship it");
4566+
assert.deepEqual(thread.updatedAt, linkedUpdatedAt);
4567+
}),
4568+
);
44944569
});
44954570

44964571
it.layer(layerSharedApplicationDataPlaneTest)("visited projection", (it) => {

‎packages/client-runtime/src/state/threadCommands.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,27 @@ describe("remote thread lifecycle commands", () => {
182182
}),
183183
);
184184

185+
it.effect("re-acknowledges an early-woken thread on a same-time re-snooze", () =>
186+
Effect.gen(function* () {
187+
const h = yield* makeHarness();
188+
const staleSnoozedAt = DateTime.makeUnsafe("2026-01-01T00:00:00.000Z");
189+
h.registry.set(h.snapshotAtom(ENVIRONMENT_ID), {
190+
...SNAPSHOT,
191+
threads: [{ ...SNAPSHOT.threads[0]!, snoozedUntil: FUTURE, snoozedAt: staleSnoozedAt }],
192+
});
193+
void h.commands.snooze.run(h.registry, {
194+
environmentId: ENVIRONMENT_ID,
195+
input: { threadId: THREAD_ID, snoozedUntil: "2099-01-01T00:00:00.000Z" },
196+
});
197+
const snoozedAt = h.registry.get(h.visibleAtom)?.threads[0]?.snoozedAt;
198+
expect(snoozedAt).toBeDefined();
199+
expect(DateTime.toEpochMillis(snoozedAt!)).toBeGreaterThan(
200+
DateTime.toEpochMillis(staleSnoozedAt),
201+
);
202+
yield* Queue.take(h.requests);
203+
}),
204+
);
205+
185206
it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () =>
186207
Effect.gen(function* () {
187208
const h = yield* makeHarness();

‎packages/client-runtime/src/state/threadCommands.ts‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,11 +440,9 @@ export function createThreadEnvironmentAtoms<R, E>(
440440
...thread,
441441
pendingRuntimeRequest: null,
442442
snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil),
443-
snoozedAt:
444-
thread.snoozedUntil != null &&
445-
DateTime.formatIso(thread.snoozedUntil) === input.snoozedUntil
446-
? (thread.snoozedAt ?? now)
447-
: now,
443+
// Mirrors the server: every snooze re-acknowledges the thread, so a
444+
// same-time re-snooze hides a thread that woke early.
445+
snoozedAt: now,
448446
},
449447
),
450448
unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({

‎packages/client-runtime/src/state/threadSettled.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,16 +102,15 @@ export interface ThreadSnoozeShell extends QueuedThreadShell {
102102
*/
103103
export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean {
104104
if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true;
105-
const runtime = shell.runtime ?? shell.session ?? null;
106105
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
107106
// Only a FRESH failure raises the hand: a thread snoozed while already
108107
// failed stays snoozed — that snooze was the user saying "I saw it, not
109-
// now". session.updatedAt stamps the status edge, so an error newer than
110-
// the snooze is new information.
108+
// now". An error newer than the snooze is new information.
109+
const failedAt = failureEdgeAt(shell);
111110
if (
112-
(runtime?.status === "error" || runtime?.status === "failed") &&
111+
failedAt !== undefined &&
113112
(shell.snoozedAt == null ||
114-
(runtime.updatedAt != null && Date.parse(runtime.updatedAt) > Date.parse(shell.snoozedAt)))
113+
(failedAt != null && Date.parse(failedAt) > Date.parse(shell.snoozedAt)))
115114
) {
116115
return true;
117116
}
@@ -126,6 +125,23 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean
126125
return false;
127126
}
128127

128+
/**
129+
* When the thread's current failure happened, or undefined when it is not
130+
* failed. A failed run's completion is the failure edge; V2 runtime
131+
* summaries reuse the thread's updatedAt, which also moves on unrelated
132+
* metadata writes (PR sync, recovery choices) and would otherwise read as a
133+
* fresh failure. Legacy sessions stamp updatedAt on the status edge itself.
134+
*/
135+
function failureEdgeAt(shell: ThreadSnoozeShell): string | null | undefined {
136+
const runtime = shell.runtime ?? shell.session ?? null;
137+
if (runtime?.status !== "error" && runtime?.status !== "failed") return undefined;
138+
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
139+
if (latestRun?.status === "failed" && latestRun.completedAt != null) {
140+
return latestRun.completedAt;
141+
}
142+
return runtime.updatedAt ?? null;
143+
}
144+
129145
/**
130146
* A thread may be snoozed unless the agent is blocked on the user: hiding a
131147
* pending approval or user-input request defeats the request, and a queued
@@ -195,7 +211,6 @@ export function threadWokeAt(
195211
// than that visit's lastVisitedAt).
196212
if (threadRaisedHandWhileSnoozed(shell)) {
197213
const latestRun = shell.latestRun ?? shell.latestTurn ?? null;
198-
const runtime = shell.runtime ?? shell.session ?? null;
199214
if (
200215
shell.snoozedAt != null &&
201216
(latestRun?.state === "completed" || latestRun?.status === "completed") &&
@@ -204,7 +219,8 @@ export function threadWokeAt(
204219
) {
205220
return latestRun.completedAt;
206221
}
207-
return runtime?.updatedAt ?? shell.snoozedAt ?? null;
222+
const runtime = shell.runtime ?? shell.session ?? null;
223+
return failureEdgeAt(shell) ?? runtime?.updatedAt ?? shell.snoozedAt ?? null;
208224
}
209225
// No raised hand: woke iff the timer elapsed (still-snoozed → null).
210226
return wakeAtMs <= Date.parse(options.now) ? shell.snoozedUntil : null;

‎packages/client-runtime/src/state/threadSnoozed.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,27 @@ describe("effectiveSnoozed", () => {
121121
).toBe(true);
122122
});
123123

124+
it("stays snoozed when only metadata moved after snoozing a failed run", () => {
125+
// V2 runtime summaries reuse thread.updatedAt, which PR sync or recovery
126+
// choices can bump long after the failure the user already saw.
127+
const shell: ThreadSnoozeShell = {
128+
...makeShell({ snoozedUntil: FUTURE_WAKE }),
129+
runtime: { status: "failed", updatedAt: "2026-04-10T11:00:00.000Z" },
130+
latestRun: { status: "failed", completedAt: "2026-04-08T13:00:00.000Z" },
131+
};
132+
expect(effectiveSnoozed(shell, { now: NOW })).toBe(true);
133+
});
134+
135+
it("wakes early when the run failed after the snooze", () => {
136+
const shell: ThreadSnoozeShell = {
137+
...makeShell({ snoozedUntil: FUTURE_WAKE }),
138+
runtime: { status: "failed", updatedAt: "2026-04-10T11:00:00.000Z" },
139+
latestRun: { status: "failed", completedAt: "2026-04-10T10:00:00.000Z" },
140+
};
141+
expect(effectiveSnoozed(shell, { now: NOW })).toBe(false);
142+
expect(threadWokeAt(shell, { now: NOW })).toBe("2026-04-10T10:00:00.000Z");
143+
});
144+
124145
it("stays snoozed while the session keeps working — snooze never pauses the agent", () => {
125146
expect(
126147
effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, sessionStatus: "running" }), {

0 commit comments

Comments
 (0)