From c23c067f5a92a0e81ee47554775ff312d4ae8ac3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 23:19:56 -0700 Subject: [PATCH 01/10] feat(v2): snooze limited threads until reset --- .../settings/SettingsThreadsRouteScreen.tsx | 12 +- .../threads/UsageLimitRecoveryCard.tsx | 45 +++++-- .../src/orchestration-v2/Orchestrator.ts | 16 +++ .../UsageLimitRecoveryWorker.ts | 17 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 111 ++++++++++++++++-- apps/web/src/components/ChatView.tsx | 2 + .../chat/UsageLimitRecoveryBanner.tsx | 17 ++- .../components/settings/SettingsPanels.tsx | 16 +++ .../src/components/settings/settingsSearch.ts | 6 + docs/user/thread-sidebar.md | 7 ++ .../src/state/sharedSettings.test.ts | 1 + .../src/state/sharedSettings.ts | 1 + packages/contracts/src/orchestrationV2.ts | 1 + packages/contracts/src/settings.ts | 2 + 14 files changed, 227 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx index eef44fa9707e9..3189756294f66 100644 --- a/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsThreadsRouteScreen.tsx @@ -87,7 +87,10 @@ function AutoSettleSettingsRows() { } const writeToAll = ( - patch: Partial & { autoResumeLimitedThreads?: boolean }, + patch: Partial & { + autoResumeLimitedThreads?: boolean; + snoozeLimitedThreads?: boolean; + }, ) => { if (writeInFlight.current) return; const writes = planMobileScopedSettingsPatch(syncTargets, projectSelected, patch); @@ -175,6 +178,13 @@ function AutoSettleSettingsRows() { disabled={disabled} onValueChange={(value) => writeToAll({ autoResumeLimitedThreads: value })} /> + writeToAll({ snoozeLimitedThreads: value })} + /> ) : null} diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 145cec4d958d2..4ceec83786186 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -32,14 +32,23 @@ export function UsageLimitRecoveryCard({ !runId ) return null; - async function toggle() { + const snoozed = resetAt !== null && thread.snoozedUntil === resetAt; + async function toggle(action: "resume" | "snooze") { if (!resetAt || !runId || !canSchedule) return; setPending(true); setError(null); try { const result = await updateMetadata({ environmentId, - input: { threadId: thread.id, limitRecovery: { runId, resetAt, autoResume: !scheduled } }, + input: { + threadId: thread.id, + limitRecovery: { + runId, + resetAt, + autoResume: action === "resume" ? !scheduled : Boolean(scheduled), + snooze: action === "snooze" ? !snoozed : snoozed, + }, + }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); } catch (cause) { @@ -56,16 +65,28 @@ export function UsageLimitRecoveryCard({ : "The provider did not report a reset time. Retry manually when your limit is available."} {canSchedule ? ( - void toggle()} - className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70" - > - - {scheduled ? "Cancel auto-resume" : "Resume at reset"} - - + + void toggle("resume")} + className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70" + > + + {scheduled ? "Cancel auto-resume" : "Resume at reset"} + + + void toggle("snooze")} + className="self-start rounded-lg bg-subtle px-3 py-2 active:opacity-70" + > + + {snoozed ? "Wake now" : "Snooze until reset"} + + + ) : null} {error ? ( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f8b2fdc005d0b..47fafe65f3562 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2414,6 +2414,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? null : { ...command.limitRecovery, requestId: command.commandId }, }), + ...(command.limitRecovery?.snooze === true && + Date.parse(command.limitRecovery.resetAt) > DateTime.toEpochMillis(now) + ? { + snoozedUntil: DateTime.makeUnsafe(command.limitRecovery.resetAt), + snoozedAt: + thread.snoozedUntil != null && + DateTime.formatIso(thread.snoozedUntil) === command.limitRecovery.resetAt + ? (thread.snoozedAt ?? now) + : now, + } + : command.limitRecovery !== undefined && + thread.limitRecovery?.snooze && + thread.snoozedUntil != null && + DateTime.formatIso(thread.snoozedUntil) === thread.limitRecovery.resetAt + ? { snoozedUntil: null, snoozedAt: null } + : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), ...(command.linkedPullRequest === undefined diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index dfadda0692776..8d7a573ac0230 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -17,6 +17,7 @@ export function limitRecoveryCommand( thread: OrchestrationV2ThreadShell, autoResume: boolean, nowMs: number, + snooze = false, ): OrchestrationV2Command | null { if ( thread.status !== "failed" || @@ -38,12 +39,17 @@ export function limitRecoveryCommand( const identity = `${thread.id}:${thread.latestRunId}:${resetMs}`; const recovery = thread.limitRecovery; if (recovery?.runId !== thread.latestRunId || recovery.resetAt !== thread.usageLimitResetAt) { - if (!autoResume) return null; + if (!autoResume && (!snooze || resetMs <= nowMs)) return null; return { type: "thread.metadata.update", commandId: CommandId.make(`limit-arm:${identity}`), threadId: thread.id, - limitRecovery: { runId: thread.latestRunId, resetAt: thread.usageLimitResetAt, autoResume }, + limitRecovery: { + runId: thread.latestRunId, + resetAt: thread.usageLimitResetAt, + autoResume, + snooze: snooze && resetMs > nowMs, + }, }; } if ( @@ -79,7 +85,12 @@ const makeSweep = Effect.gen(function* () { const snapshot = yield* projections.getShellSnapshot(); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); for (const thread of snapshot.threads) { - const command = limitRecoveryCommand(thread, preferences.autoResumeLimitedThreads, nowMs); + const command = limitRecoveryCommand( + thread, + preferences.autoResumeLimitedThreads, + nowMs, + preferences.snoozeLimitedThreads, + ); if (command === null) continue; yield* threads.dispatch(command).pipe( Effect.catchCause((cause) => diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 50e4e6990c32f..9e965a6010324 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2270,6 +2270,30 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: "queue_after_active" }, }); + if (scenario === "rearm") { + yield* orchestrator.dispatch(resume!); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:rearm:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }); + yield* orchestrator.dispatch(resume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + const freshResume = limitRecoveryCommand( + rearmedShell, + true, + DateTime.toEpochMillis(yield* DateTime.now), + ); + assert.isNotNull(freshResume); + assert.notEqual(freshResume!.commandId, resume!.commandId); + yield* orchestrator.dispatch(freshResume!); + yield* orchestrator.dispatch(freshResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + } const before = yield* orchestrator.getThreadProjection(threadId); const activeRun = before.runs.find((run) => run.status === "starting"); const queuedRuns = before.runs @@ -2441,6 +2465,30 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, }); } + if (scenario === "rearm") { + yield* orchestrator.dispatch(resume!); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:rearm:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }); + yield* orchestrator.dispatch(resume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + const freshResume = limitRecoveryCommand( + rearmedShell, + true, + DateTime.toEpochMillis(yield* DateTime.now), + ); + assert.isNotNull(freshResume); + assert.notEqual(freshResume!.commandId, resume!.commandId); + yield* orchestrator.dispatch(freshResume!); + yield* orchestrator.dispatch(freshResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + } const before = yield* orchestrator.getThreadProjection(threadId); const queued = before.runs.filter((run) => run.status === "queued"); assert.equal(queued.length, 2); @@ -2556,6 +2604,30 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: "queue_after_active" }, }); + if (scenario === "rearm") { + yield* orchestrator.dispatch(resume!); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:rearm:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }); + yield* orchestrator.dispatch(resume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + const freshResume = limitRecoveryCommand( + rearmedShell, + true, + DateTime.toEpochMillis(yield* DateTime.now), + ); + assert.isNotNull(freshResume); + assert.notEqual(freshResume!.commandId, resume!.commandId); + yield* orchestrator.dispatch(freshResume!); + yield* orchestrator.dispatch(freshResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + } const before = yield* orchestrator.getThreadProjection(threadId); const queuedRun = before.runs.find((run) => run.status === "queued"); assert.isDefined(queuedRun); @@ -3026,6 +3098,9 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "archive", "settle", "replacement", + "snooze-only", + "snooze-resume", + "wake", ] as const)("guards a scheduled usage-limit continuation against %s", (scenario) => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -3121,7 +3196,9 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { (thread) => thread.id === threadId, )!; assert.isNull(limitRecoveryCommand(shell, false, DateTime.toEpochMillis(now))); - const arm = limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now)); + const snooze = ["snooze-only", "snooze-resume", "wake"].includes(scenario); + const autoResume = scenario !== "snooze-only" && scenario !== "wake"; + const arm = limitRecoveryCommand(shell, autoResume, DateTime.toEpochMillis(now), snooze); assert.isNotNull(arm); yield* orchestrator.dispatch(arm!); const armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( @@ -3130,9 +3207,20 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { assert.deepEqual(armedShell.limitRecovery, { runId: run.id, resetAt, - autoResume: true, + autoResume, + snooze, requestId: arm!.commandId, }); + if (snooze) assert.equal(DateTime.formatIso(armedShell.snoozedUntil!), resetAt); + if (scenario === "wake") { + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:wake:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: false, snooze: false }, + }); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntil); + } assert.isNull(limitRecoveryCommand(armedShell, true, DateTime.toEpochMillis(now))); yield* orchestrator.dispatch({ type: "message.dispatch", @@ -3153,7 +3241,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { true, DateTime.toEpochMillis(yield* DateTime.now), ); - assert.isNotNull(resume); + if (autoResume) assert.isNotNull(resume); + else assert.isNull(resume); if (scenario === "snooze-race") { const wakeAt = DateTime.formatIso(DateTime.add(yield* DateTime.now, { minutes: 1 })); yield* orchestrator.dispatch({ @@ -3259,11 +3348,19 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); } const before = yield* orchestrator.getThreadProjection(threadId); - yield* orchestrator.dispatch(resume!); - yield* orchestrator.dispatch(resume!); + if (resume !== null) { + yield* orchestrator.dispatch(resume); + yield* orchestrator.dispatch(resume); + } const after = yield* orchestrator.getThreadProjection(threadId); - assert.lengthOf(after.runs, before.runs.length + (scenario === "resume" ? 1 : 0)); - assert.lengthOf(after.messages, before.messages.length + (scenario === "resume" ? 1 : 0)); + assert.lengthOf( + after.runs, + before.runs.length + (scenario === "resume" || scenario === "snooze-resume" ? 1 : 0), + ); + assert.lengthOf( + after.messages, + before.messages.length + (scenario === "resume" || scenario === "snooze-resume" ? 1 : 0), + ); }), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c6b1fb81f7fb0..bb2bd08c7d697 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6983,6 +6983,8 @@ export default function ChatView(props: ChatViewProps) { resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, + snoozedUntil: activeThreadShell.snoozedUntil, + nowMs: Date.parse(`${nowMinute}:00Z`), onChange: async (limitRecovery) => { const result = await updateThreadMetadata({ environmentId, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 2d2992cc092ad..21edd5eaa4ee2 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -8,6 +8,8 @@ type RecoveryProps = { runId: RunId; resetAt: string | null; stoppedAt: string; + snoozedUntil: string | null; + nowMs: number; recovery: OrchestrationV2LimitRecovery | null; onChange: (recovery: OrchestrationV2LimitRecovery) => Promise; }; @@ -28,17 +30,21 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann }; } -function RecoveryActions({ runId, resetAt, recovery, onChange }: RecoveryProps) { +function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, nowMs, onChange }: RecoveryProps) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); const scheduled = recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume; - async function toggle() { + const snoozed = recovery?.snooze === true && recovery.runId === runId && recovery.resetAt === resetAt && resetAt !== null && snoozedUntil !== null && Date.parse(snoozedUntil) === Date.parse(resetAt); + async function toggle(action: "resume" | "snooze") { if (resetAt === null) return; setPending(true); setError(null); try { - await onChange({ runId, resetAt, autoResume: !scheduled }); + await onChange({ runId, resetAt, + autoResume: action === "resume" ? !scheduled : Boolean(scheduled), + snooze: action === "snooze" ? !snoozed : snoozed, + }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); } finally { @@ -47,9 +53,12 @@ function RecoveryActions({ runId, resetAt, recovery, onChange }: RecoveryProps) } return (
- + {error ? (

{error} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 52e6103fc96ab..4b501327e06e8 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2244,6 +2244,22 @@ export function GeneralSettingsPanel() { /> } /> + + updateSettings({ snoozeLimitedThreads: Boolean(checked) }) + } + aria-label="Snooze limited threads" + /> + } + /> {supportsAutoSettlement ? ( <> { "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", + "snoozeLimitedThreads", "sourceControlWritingStyle", "textGenerationModelSelection", ]); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index ed8d36276e6a7..1d6fb5e14ca77 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -26,6 +26,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", + "snoozeLimitedThreads", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 1c62d140c247c..b13a5971d31e6 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -331,6 +331,7 @@ export const OrchestrationV2LimitRecovery = Schema.Struct({ runId: RunId, resetAt: IsoDateTime, autoResume: Schema.Boolean, + snooze: Schema.optional(Schema.Boolean), }); export type OrchestrationV2LimitRecovery = typeof OrchestrationV2LimitRecovery.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a3821686b9254..f7719df2a628a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1200,6 +1200,7 @@ export const ServerSettings = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), + snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, @@ -1532,6 +1533,7 @@ export const ServerSettingsPatch = Schema.Struct({ sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), + snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), From 41b597743e2898da6a7ed864d0031d5691bcafea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 23:38:08 -0700 Subject: [PATCH 02/10] fix(v2): normalize snooze times and settings search --- .../src/features/threads/UsageLimitRecoveryCard.tsx | 5 ++++- apps/server/src/orchestration-v2/Orchestrator.ts | 6 ++++-- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 8 ++++++-- apps/web/src/components/settings/SettingsPanels.tsx | 4 ++-- docs/user/thread-sidebar.md | 2 +- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 4ceec83786186..5908b0dee3159 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -32,7 +32,10 @@ export function UsageLimitRecoveryCard({ !runId ) return null; - const snoozed = resetAt !== null && thread.snoozedUntil === resetAt; + const snoozed = + resetAt !== null && + thread.snoozedUntil !== null && + Date.parse(thread.snoozedUntil) === Date.parse(resetAt); async function toggle(action: "resume" | "snooze") { if (!resetAt || !runId || !canSchedule) return; setPending(true); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 47fafe65f3562..34427e905eb80 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2420,14 +2420,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio snoozedUntil: DateTime.makeUnsafe(command.limitRecovery.resetAt), snoozedAt: thread.snoozedUntil != null && - DateTime.formatIso(thread.snoozedUntil) === command.limitRecovery.resetAt + DateTime.toEpochMillis(thread.snoozedUntil) === + Date.parse(command.limitRecovery.resetAt) ? (thread.snoozedAt ?? now) : now, } : command.limitRecovery !== undefined && thread.limitRecovery?.snooze && thread.snoozedUntil != null && - DateTime.formatIso(thread.snoozedUntil) === thread.limitRecovery.resetAt + DateTime.toEpochMillis(thread.snoozedUntil) === + Date.parse(thread.limitRecovery.resetAt) ? { snoozedUntil: null, snoozedAt: null } : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9e965a6010324..5074413565b4c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3149,7 +3149,10 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { const projection = yield* orchestrator.getThreadProjection(threadId); const run = projection.runs[0]!; const now = yield* DateTime.now; - const resetAt = DateTime.formatIso(DateTime.add(now, { minutes: 1 })); + const resetAt = DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace( + "Z", + scenario === "wake" ? "+00:00" : "Z", + ); yield* events.write({ commandId: CommandId.make(`recovery:failure:${scenario}`), events: [ @@ -3211,7 +3214,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { snooze, requestId: arm!.commandId, }); - if (snooze) assert.equal(DateTime.formatIso(armedShell.snoozedUntil!), resetAt); + if (snooze) + assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); if (scenario === "wake") { yield* orchestrator.dispatch({ type: "thread.metadata.update", diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 4b501327e06e8..6536c4e904b9a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2246,8 +2246,8 @@ export function GeneralSettingsPanel() { /> Date: Sat, 19 Sep 2026 23:39:34 -0700 Subject: [PATCH 03/10] test(v2): keep recovery race coverage in its scenario --- .../src/orchestration-v2/runtimeLayer.test.ts | 72 ------------------- 1 file changed, 72 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 5074413565b4c..85f59f5e36bfb 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2270,30 +2270,6 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: "queue_after_active" }, }); - if (scenario === "rearm") { - yield* orchestrator.dispatch(resume!); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`recovery:rearm:${scenario}`), - threadId, - limitRecovery: { runId: run.id, resetAt, autoResume: true }, - }); - yield* orchestrator.dispatch(resume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); - const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( - (thread) => thread.id === threadId, - )!; - const freshResume = limitRecoveryCommand( - rearmedShell, - true, - DateTime.toEpochMillis(yield* DateTime.now), - ); - assert.isNotNull(freshResume); - assert.notEqual(freshResume!.commandId, resume!.commandId); - yield* orchestrator.dispatch(freshResume!); - yield* orchestrator.dispatch(freshResume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); - } const before = yield* orchestrator.getThreadProjection(threadId); const activeRun = before.runs.find((run) => run.status === "starting"); const queuedRuns = before.runs @@ -2465,30 +2441,6 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: index === 0 ? "start_immediately" : "queue_after_active" }, }); } - if (scenario === "rearm") { - yield* orchestrator.dispatch(resume!); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`recovery:rearm:${scenario}`), - threadId, - limitRecovery: { runId: run.id, resetAt, autoResume: true }, - }); - yield* orchestrator.dispatch(resume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); - const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( - (thread) => thread.id === threadId, - )!; - const freshResume = limitRecoveryCommand( - rearmedShell, - true, - DateTime.toEpochMillis(yield* DateTime.now), - ); - assert.isNotNull(freshResume); - assert.notEqual(freshResume!.commandId, resume!.commandId); - yield* orchestrator.dispatch(freshResume!); - yield* orchestrator.dispatch(freshResume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); - } const before = yield* orchestrator.getThreadProjection(threadId); const queued = before.runs.filter((run) => run.status === "queued"); assert.equal(queued.length, 2); @@ -2604,30 +2556,6 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: "queue_after_active" }, }); - if (scenario === "rearm") { - yield* orchestrator.dispatch(resume!); - yield* orchestrator.dispatch({ - type: "thread.metadata.update", - commandId: CommandId.make(`recovery:rearm:${scenario}`), - threadId, - limitRecovery: { runId: run.id, resetAt, autoResume: true }, - }); - yield* orchestrator.dispatch(resume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); - const rearmedShell = (yield* orchestrator.getShellSnapshot()).threads.find( - (thread) => thread.id === threadId, - )!; - const freshResume = limitRecoveryCommand( - rearmedShell, - true, - DateTime.toEpochMillis(yield* DateTime.now), - ); - assert.isNotNull(freshResume); - assert.notEqual(freshResume!.commandId, resume!.commandId); - yield* orchestrator.dispatch(freshResume!); - yield* orchestrator.dispatch(freshResume!); - assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); - } const before = yield* orchestrator.getThreadProjection(threadId); const queuedRun = before.runs.find((run) => run.status === "queued"); assert.isDefined(queuedRun); From c9d2e7d2d0b591b1f579ddc150e45fad7377c8bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 19 Sep 2026 23:43:56 -0700 Subject: [PATCH 04/10] fix(v2): distinguish normal snoozes from limit recovery --- .../threads/UsageLimitRecoveryCard.tsx | 3 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 5908b0dee3159..3b0398ff7ff4e 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -33,6 +33,9 @@ export function UsageLimitRecoveryCard({ ) return null; const snoozed = + recovery?.snooze === true && + recovery.runId === runId && + recovery.resetAt === resetAt && resetAt !== null && thread.snoozedUntil !== null && Date.parse(thread.snoozedUntil) === Date.parse(resetAt); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 85f59f5e36bfb..aafdcf6a5e054 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3026,6 +3026,7 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "archive", "settle", "replacement", + "manual-snooze", "snooze-only", "snooze-resume", "wake", @@ -3144,6 +3145,32 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { }); if (snooze) assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); + if (scenario === "manual-snooze") { + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(`recovery:manual-snooze:${scenario}`), + threadId, + snoozedUntil: resetAt, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:manual-cancel:${scenario}`), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: false, snooze: false }, + }); + assert.equal( + DateTime.toEpochMillis( + (yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntil!, + ), + Date.parse(resetAt), + ); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make(`recovery:manual-wake:${scenario}`), + threadId, + }); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntil); + } if (scenario === "wake") { yield* orchestrator.dispatch({ type: "thread.metadata.update", From 673b758e1a6f48e552e3bbb5243d67ecc32433ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 00:38:38 -0700 Subject: [PATCH 05/10] fix(v2): keep snooze active when recovery choices change --- .../src/orchestration-v2/Orchestrator.ts | 9 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 60 +++++++++++++++++-- apps/web/src/components/ChatView.tsx | 13 +++- .../chat/UsageLimitRecoveryBanner.tsx | 28 +++++++-- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 34427e905eb80..ed0b1aca0404d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2418,12 +2418,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio Date.parse(command.limitRecovery.resetAt) > DateTime.toEpochMillis(now) ? { snoozedUntil: DateTime.makeUnsafe(command.limitRecovery.resetAt), - snoozedAt: - thread.snoozedUntil != null && - DateTime.toEpochMillis(thread.snoozedUntil) === - Date.parse(command.limitRecovery.resetAt) - ? (thread.snoozedAt ?? now) - : now, + // Recovery changes acknowledge the same stopped run; keep its + // metadata timestamp from appearing as a fresh failure wake. + snoozedAt: now, } : command.limitRecovery !== undefined && thread.limitRecovery?.snooze && diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index aafdcf6a5e054..b7d2204e90871 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3029,6 +3029,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "manual-snooze", "snooze-only", "snooze-resume", + "cancel-resume-keep-snooze", + "wake-preserve-resume", "wake", ] as const)("guards a scheduled usage-limit continuation against %s", (scenario) => Effect.gen(function* () { @@ -3128,12 +3130,18 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { (thread) => thread.id === threadId, )!; assert.isNull(limitRecoveryCommand(shell, false, DateTime.toEpochMillis(now))); - const snooze = ["snooze-only", "snooze-resume", "wake"].includes(scenario); + const snooze = [ + "snooze-only", + "snooze-resume", + "wake", + "cancel-resume-keep-snooze", + "wake-preserve-resume", + ].includes(scenario); const autoResume = scenario !== "snooze-only" && scenario !== "wake"; const arm = limitRecoveryCommand(shell, autoResume, DateTime.toEpochMillis(now), snooze); assert.isNotNull(arm); yield* orchestrator.dispatch(arm!); - const armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + let armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, )!; assert.deepEqual(armedShell.limitRecovery, { @@ -3145,6 +3153,37 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { }); if (snooze) assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); + if (scenario === "cancel-resume-keep-snooze" || scenario === "wake-preserve-resume") { + yield* TestClock.adjust("10 seconds"); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`recovery:independent-choice:${scenario}`), + threadId, + limitRecovery: { + runId: run.id, + resetAt, + autoResume: scenario === "wake-preserve-resume", + snooze: scenario === "cancel-resume-keep-snooze", + }, + }); + armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + if (scenario === "cancel-resume-keep-snooze") { + assert.equal(DateTime.toEpochMillis(armedShell.snoozedUntil!), Date.parse(resetAt)); + // Failed runtime timestamps advance with metadata. Acknowledging the + // same failed run must not turn cancellation into an early wake. + assert.equal( + DateTime.toEpochMillis(armedShell.snoozedAt!), + DateTime.toEpochMillis(armedShell.updatedAt), + ); + assert.isFalse(armedShell.limitRecovery!.autoResume); + } else { + assert.isNull(armedShell.snoozedUntil); + assert.isNull(armedShell.snoozedAt); + assert.isTrue(armedShell.limitRecovery!.autoResume); + } + } if (scenario === "manual-snooze") { yield* orchestrator.dispatch({ type: "thread.snooze", @@ -3168,6 +3207,7 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { type: "thread.unsnooze", commandId: CommandId.make(`recovery:manual-wake:${scenario}`), threadId, + reason: "user", }); assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntil); } @@ -3200,7 +3240,7 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { true, DateTime.toEpochMillis(yield* DateTime.now), ); - if (autoResume) assert.isNotNull(resume); + if (autoResume && scenario !== "cancel-resume-keep-snooze") assert.isNotNull(resume); else assert.isNull(resume); if (scenario === "snooze-race") { const wakeAt = DateTime.formatIso(DateTime.add(yield* DateTime.now, { minutes: 1 })); @@ -3314,11 +3354,21 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { const after = yield* orchestrator.getThreadProjection(threadId); assert.lengthOf( after.runs, - before.runs.length + (scenario === "resume" || scenario === "snooze-resume" ? 1 : 0), + before.runs.length + + (scenario === "resume" || + scenario === "snooze-resume" || + scenario === "wake-preserve-resume" + ? 1 + : 0), ); assert.lengthOf( after.messages, - before.messages.length + (scenario === "resume" || scenario === "snooze-resume" ? 1 : 0), + before.messages.length + + (scenario === "resume" || + scenario === "snooze-resume" || + scenario === "wake-preserve-resume" + ? 1 + : 0), ); }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bb2bd08c7d697..1919a396de51d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -7000,7 +7000,16 @@ export default function ChatView(props: ChatViewProps) { const resumeCompactionItems = resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; - const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + const recoveryOwnsSnooze = + limitRecoveryBanner !== null && + activeThreadShell?.limitRecovery?.snooze === true && + activeThreadShell.limitRecovery.runId === activeThreadShell.latestRun?.runId && + activeThreadShell.limitRecovery.resetAt === serverRuntime?.usageLimitResetAt && + activeThreadShell.snoozedUntil !== null && + Date.parse(activeThreadShell.snoozedUntil) === + Date.parse(activeThreadShell.limitRecovery.resetAt); + const parkedThreadItems = + parkedThreadBannerItem === null || recoveryOwnsSnooze ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; @@ -7068,6 +7077,8 @@ export default function ChatView(props: ChatViewProps) { ]; }, [ activeBranchMismatchKey, + activeThreadShell, + serverRuntime?.usageLimitResetAt, feedbackBannerItems, limitRecoveryBanner, handleRestoreThreadBranch, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 21edd5eaa4ee2..18057b6fff1db 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -30,18 +30,33 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann }; } -function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, nowMs, onChange }: RecoveryProps) { +function RecoveryActions({ + runId, + resetAt, + recovery, + snoozedUntil, + nowMs, + onChange, +}: RecoveryProps) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); const scheduled = recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume; - const snoozed = recovery?.snooze === true && recovery.runId === runId && recovery.resetAt === resetAt && resetAt !== null && snoozedUntil !== null && Date.parse(snoozedUntil) === Date.parse(resetAt); + const snoozed = + recovery?.snooze === true && + recovery.runId === runId && + recovery.resetAt === resetAt && + resetAt !== null && + snoozedUntil !== null && + Date.parse(snoozedUntil) === Date.parse(resetAt); async function toggle(action: "resume" | "snooze") { if (resetAt === null) return; setPending(true); setError(null); try { - await onChange({ runId, resetAt, + await onChange({ + runId, + resetAt, autoResume: action === "resume" ? !scheduled : Boolean(scheduled), snooze: action === "snooze" ? !snoozed : snoozed, }); @@ -56,7 +71,12 @@ function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, nowMs, onChan - {error ? ( From a0e38ee97bf8d8851e89fa638e4c1f4214b3fd40 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 00:50:36 -0700 Subject: [PATCH 06/10] fix(v2): preserve independent limit recovery choices --- .../threads/UsageLimitRecoveryCard.tsx | 7 +- .../src/orchestration-v2/Orchestrator.ts | 46 +++++++++---- .../src/orchestration-v2/runtimeLayer.test.ts | 65 ++++++++++++++++++- apps/web/src/components/ChatView.tsx | 1 - .../chat/UsageLimitRecoveryBanner.tsx | 38 ++++++----- .../client-runtime/src/operations/commands.ts | 2 +- packages/contracts/src/orchestrationV2.ts | 11 +++- 7 files changed, 136 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 3b0398ff7ff4e..0b902e25db60e 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -41,6 +41,10 @@ export function UsageLimitRecoveryCard({ Date.parse(thread.snoozedUntil) === Date.parse(resetAt); async function toggle(action: "resume" | "snooze") { if (!resetAt || !runId || !canSchedule) return; + if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) { + setError("The reset time has passed. Retry the thread manually."); + return; + } setPending(true); setError(null); try { @@ -51,8 +55,7 @@ export function UsageLimitRecoveryCard({ limitRecovery: { runId, resetAt, - autoResume: action === "resume" ? !scheduled : Boolean(scheduled), - snooze: action === "snooze" ? !snoozed : snoozed, + ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }), }, }, }); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ed0b1aca0404d..c910143daf898 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2231,6 +2231,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* loadProjectionForCommand(command); const run = projection.runs.at(-1) ?? null; const failure = latestRootProviderFailure(run, projection.turnItems); + if ( + command.limitRecovery.snooze === true && + Date.parse(command.limitRecovery.resetAt) <= DateTime.toEpochMillis(now) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The reset time has passed. Retry the thread manually.", + }); + } if ( thread.archivedAt !== null || thread.settledOverride === "settled" || @@ -2402,22 +2412,33 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } case "thread.mark-unread": return { ...thread, lastVisitedAt: markUnreadVisitedAt }; - case "thread.metadata.update": + case "thread.metadata.update": { + const previousRecovery = + thread.limitRecovery?.runId === command.limitRecovery?.runId && + thread.limitRecovery?.resetAt === command.limitRecovery?.resetAt + ? thread.limitRecovery + : null; + const limitRecovery = + command.limitRecovery === undefined + ? thread.limitRecovery + : command.limitRecovery === null + ? null + : { + ...command.limitRecovery, + autoResume: + command.limitRecovery.autoResume ?? previousRecovery?.autoResume ?? false, + snooze: command.limitRecovery.snooze ?? previousRecovery?.snooze ?? false, + requestId: command.commandId, + }; return { ...thread, ...(command.title === undefined ? {} : { title: command.title }), - ...(command.limitRecovery === undefined - ? {} - : { - limitRecovery: - command.limitRecovery === null - ? null - : { ...command.limitRecovery, requestId: command.commandId }, - }), - ...(command.limitRecovery?.snooze === true && - Date.parse(command.limitRecovery.resetAt) > DateTime.toEpochMillis(now) + ...(command.limitRecovery === undefined ? {} : { limitRecovery }), + ...(command.limitRecovery !== undefined && + limitRecovery?.snooze === true && + Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) ? { - snoozedUntil: DateTime.makeUnsafe(command.limitRecovery.resetAt), + snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt), // Recovery changes acknowledge the same stopped run; keep its // metadata timestamp from appearing as a fresh failure wake. snoozedAt: now, @@ -2476,6 +2497,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : {}), updatedAt: now, }; + } case "thread.pull-request.link": case "thread.pull-request.unlink": case "thread.pull-request-link.sync": { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index b7d2204e90871..c97d93345c625 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3031,6 +3031,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "snooze-resume", "cancel-resume-keep-snooze", "wake-preserve-resume", + "independent-patches", + "expired-snooze", "wake", ] as const)("guards a scheduled usage-limit continuation against %s", (scenario) => Effect.gen(function* () { @@ -3184,6 +3186,45 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { assert.isTrue(armedShell.limitRecovery!.autoResume); } } + if (scenario === "independent-patches") { + yield* TestClock.adjust("10 seconds"); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:patch-snooze"), + threadId, + limitRecovery: { runId: run.id, resetAt, snooze: true }, + }); + let current = yield* orchestrator.getThreadProjection(threadId); + assert.isTrue(current.thread.limitRecovery!.autoResume); + assert.isTrue(current.thread.limitRecovery!.snooze); + // This is also the payload an older auto-resume-only client sends. + yield* TestClock.adjust("10 seconds"); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:patch-cancel-resume"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: false }, + }); + current = yield* orchestrator.getThreadProjection(threadId); + assert.isFalse(current.thread.limitRecovery!.autoResume); + assert.isTrue(current.thread.limitRecovery!.snooze); + assert.equal(DateTime.toEpochMillis(current.thread.snoozedUntil!), Date.parse(resetAt)); + assert.equal( + DateTime.toEpochMillis(current.thread.snoozedAt!), + DateTime.toEpochMillis(current.thread.updatedAt), + ); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:patch-resume"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }); + armedShell = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + assert.isTrue(armedShell.limitRecovery!.autoResume); + assert.isTrue(armedShell.limitRecovery!.snooze); + } if (scenario === "manual-snooze") { yield* orchestrator.dispatch({ type: "thread.snooze", @@ -3267,6 +3308,22 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { yield* orchestrator.dispatch(freshResume!); assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); } + if (scenario === "expired-snooze") { + const staleSnooze = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:expired-snooze"), + threadId, + limitRecovery: { runId: run.id, resetAt, snooze: true }, + }) + .pipe(Effect.exit); + assert.equal(staleSnooze._tag, "Failure"); + const current = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(current.thread.snoozedUntil); + assert.isFalse(current.thread.limitRecovery!.snooze); + assert.isTrue(current.thread.limitRecovery!.autoResume); + } + if (scenario === "cancel" || scenario === "rearm") yield* orchestrator.dispatch({ type: "thread.metadata.update", @@ -3357,7 +3414,9 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { before.runs.length + (scenario === "resume" || scenario === "snooze-resume" || - scenario === "wake-preserve-resume" + scenario === "wake-preserve-resume" || + scenario === "independent-patches" || + scenario === "expired-snooze" ? 1 : 0), ); @@ -3366,7 +3425,9 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { before.messages.length + (scenario === "resume" || scenario === "snooze-resume" || - scenario === "wake-preserve-resume" + scenario === "wake-preserve-resume" || + scenario === "independent-patches" || + scenario === "expired-snooze" ? 1 : 0), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1919a396de51d..73ad028aed403 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6984,7 +6984,6 @@ export default function ChatView(props: ChatViewProps) { stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, snoozedUntil: activeThreadShell.snoozedUntil, - nowMs: Date.parse(`${nowMinute}:00Z`), onChange: async (limitRecovery) => { const result = await updateThreadMetadata({ environmentId, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 18057b6fff1db..2fd86b312dd14 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -1,6 +1,10 @@ -import { type OrchestrationV2LimitRecovery, type RunId } from "@t3tools/contracts"; +import { + type OrchestrationV2LimitRecovery, + type OrchestrationV2LimitRecoveryUpdate, + type RunId, +} from "@t3tools/contracts"; import { GaugeIcon } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "../ui/button"; import type { ComposerBannerStackItem } from "./ComposerBannerStack"; @@ -9,9 +13,8 @@ type RecoveryProps = { resetAt: string | null; stoppedAt: string; snoozedUntil: string | null; - nowMs: number; recovery: OrchestrationV2LimitRecovery | null; - onChange: (recovery: OrchestrationV2LimitRecovery) => Promise; + onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise; }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { @@ -26,20 +29,21 @@ export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBann description: resetAt ? `Resets ${new Date(resetAt).toLocaleString()}` : "Reset time unavailable; retry manually", - actions: canSchedule ? : null, + actions: canSchedule ? : null, }; } -function RecoveryActions({ - runId, - resetAt, - recovery, - snoozedUntil, - nowMs, - onChange, -}: RecoveryProps) { +function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: RecoveryProps) { const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + const delay = Date.parse(resetAt ?? "") - Math.max(nowMs, Date.now()); + if (!Number.isFinite(delay) || delay <= 0) return; + const timer = window.setTimeout(() => setNowMs(Date.now()), Math.min(delay + 1, 2_147_483_647)); + return () => window.clearTimeout(timer); + }, [resetAt, nowMs]); + const scheduled = recovery?.runId === runId && recovery.resetAt === resetAt && recovery.autoResume; const snoozed = @@ -51,14 +55,18 @@ function RecoveryActions({ Date.parse(snoozedUntil) === Date.parse(resetAt); async function toggle(action: "resume" | "snooze") { if (resetAt === null) return; + if (action === "snooze" && !snoozed && Date.parse(resetAt) <= Date.now()) { + setError("The reset time has passed. Retry the thread manually."); + setNowMs(Date.now()); + return; + } setPending(true); setError(null); try { await onChange({ runId, resetAt, - autoResume: action === "resume" ? !scheduled : Boolean(scheduled), - snooze: action === "snooze" ? !snoozed : snoozed, + ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }), }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 45e7f093e76a9..19d7e4120b3e8 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -121,7 +121,7 @@ export interface VisitThreadInput extends ThreadCommandInput { export type MarkThreadUnreadInput = ThreadCommandInput; export interface UpdateThreadMetadataInput extends ThreadCommandInput { - readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; + readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecoveryUpdate | null; readonly title?: string; readonly modelSelection?: ModelSelection; readonly branch?: string | null; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index b13a5971d31e6..345fa668abc45 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -335,6 +335,15 @@ export const OrchestrationV2LimitRecovery = Schema.Struct({ }); export type OrchestrationV2LimitRecovery = typeof OrchestrationV2LimitRecovery.Type; +/** A choice update preserves omitted options for this same run and reset. */ +export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ + runId: RunId, + resetAt: IsoDateTime, + autoResume: Schema.optional(Schema.Boolean), + snooze: Schema.optional(Schema.Boolean), +}); +export type OrchestrationV2LimitRecoveryUpdate = typeof OrchestrationV2LimitRecoveryUpdate.Type; + export const OrchestrationV2AppThread = Schema.Struct({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -2340,7 +2349,7 @@ export const OrchestrationV2Command = Schema.Union([ expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), /** Reject unless no message or run has landed on this thread. */ expectedEmpty: Schema.optional(Schema.Boolean), - limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), + limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecoveryUpdate)), /** Link (object) or unlink (null) a pull request (#8160); absent leaves it unchanged. */ linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), }), From a2e6e91123627dd91f53424a97f98626bc179262 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 12:08:04 -0700 Subject: [PATCH 07/10] fix(web): reuse nested snooze banner for waking --- apps/web/src/components/ChatView.tsx | 42 ++++++++++++------- .../chat/UsageLimitRecoveryBanner.tsx | 18 ++++---- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 73ad028aed403..dab0ccd4d1f6b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6614,10 +6614,25 @@ export default function ChatView(props: ChatViewProps) { const threadKey = scopedThreadKey(activeThreadRef); setUnsnoozingThreadKey(threadKey); try { - const result = await unsnoozeThreadMutation({ - environmentId: activeThreadRef.environmentId, - input: { threadId: activeThreadRef.threadId, reason: "user" }, - }); + const recovery = activeThreadShell?.limitRecovery; + const recoveryOwnsSnooze = + recovery?.snooze === true && + recovery.runId === activeThreadShell?.latestRun?.runId && + recovery.resetAt === serverRuntime?.usageLimitResetAt && + activeThreadShell?.snoozedUntil != null && + Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); + const result = recoveryOwnsSnooze + ? await updateThreadMetadata({ + environmentId: activeThreadRef.environmentId, + input: { + threadId: activeThreadRef.threadId, + limitRecovery: { runId: recovery.runId, resetAt: recovery.resetAt, snooze: false }, + }, + }) + : await unsnoozeThreadMutation({ + environmentId: activeThreadRef.environmentId, + input: { threadId: activeThreadRef.threadId, reason: "user" }, + }); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -6631,7 +6646,13 @@ export default function ChatView(props: ChatViewProps) { } finally { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } - }, [activeThreadRef, unsnoozeThreadMutation]); + }, [ + activeThreadRef, + activeThreadShell, + serverRuntime?.usageLimitResetAt, + unsnoozeThreadMutation, + updateThreadMetadata, + ]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the @@ -6999,16 +7020,7 @@ export default function ChatView(props: ChatViewProps) { const resumeCompactionItems = resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; - const recoveryOwnsSnooze = - limitRecoveryBanner !== null && - activeThreadShell?.limitRecovery?.snooze === true && - activeThreadShell.limitRecovery.runId === activeThreadShell.latestRun?.runId && - activeThreadShell.limitRecovery.resetAt === serverRuntime?.usageLimitResetAt && - activeThreadShell.snoozedUntil !== null && - Date.parse(activeThreadShell.snoozedUntil) === - Date.parse(activeThreadShell.limitRecovery.resetAt); - const parkedThreadItems = - parkedThreadBannerItem === null || recoveryOwnsSnooze ? [] : [parkedThreadBannerItem]; + const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; // The user asked for this one, so it leads the notice tier instead of trailing it. const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 2fd86b312dd14..56ea382954b60 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -79,14 +79,16 @@ function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: R - + {!snoozed ? ( + + ) : null} {error ? (

{error} From 4a9ee28f73e41256e795364118468b05e383424d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 17:54:50 -0700 Subject: [PATCH 08/10] fix(v2): reject empty recovery choices and wake from shell state --- apps/web/src/components/ChatView.tsx | 9 +-------- packages/contracts/src/orchestrationV2.test.ts | 18 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 9 ++++++++- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dab0ccd4d1f6b..efaa90d4a44e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6618,7 +6618,6 @@ export default function ChatView(props: ChatViewProps) { const recoveryOwnsSnooze = recovery?.snooze === true && recovery.runId === activeThreadShell?.latestRun?.runId && - recovery.resetAt === serverRuntime?.usageLimitResetAt && activeThreadShell?.snoozedUntil != null && Date.parse(activeThreadShell.snoozedUntil) === Date.parse(recovery.resetAt); const result = recoveryOwnsSnooze @@ -6646,13 +6645,7 @@ export default function ChatView(props: ChatViewProps) { } finally { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } - }, [ - activeThreadRef, - activeThreadShell, - serverRuntime?.usageLimitResetAt, - unsnoozeThreadMutation, - updateThreadMetadata, - ]); + }, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]); const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false); const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false); // Once revealed for a given mismatch, the banner stays mounted until the diff --git a/packages/contracts/src/orchestrationV2.test.ts b/packages/contracts/src/orchestrationV2.test.ts index ea34526d294af..3677dbd101751 100644 --- a/packages/contracts/src/orchestrationV2.test.ts +++ b/packages/contracts/src/orchestrationV2.test.ts @@ -24,6 +24,7 @@ import { OrchestrationV2Checkpoint, OrchestrationV2CheckpointScope, OrchestrationV2Command, + OrchestrationV2LimitRecoveryUpdate, OrchestrationV2DomainEvent, OrchestrationV2ProviderCapabilities, OrchestrationV2ProviderThread, @@ -1025,3 +1026,20 @@ it("round-trips typed notifications and keeps work outcome separate from item st decodeOrchestrationV2TurnItem({ ...base, source: { kind: "delegated_task" }, updatedAt: now }), ).toThrow(); }); + +describe("limit recovery choice updates", () => { + const decode = Schema.decodeUnknownSync(OrchestrationV2LimitRecoveryUpdate); + const identity = { runId: "run:limited", resetAt: "2026-09-20T21:00:00.000Z" }; + it("rejects updates that would disable defaults without choosing an option", () => { + expect(() => decode(identity)).toThrow("A recovery update must include autoResume or snooze"); + }); + it.each([ + { autoResume: true }, + { autoResume: false }, + { snooze: true }, + { snooze: false }, + { autoResume: true, snooze: false }, + ])("accepts an explicit independent choice %j", (choice) => { + expect(decode({ ...identity, ...choice })).toEqual({ ...identity, ...choice }); + }); +}); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 345fa668abc45..f6218f1899b15 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -341,7 +341,14 @@ export const OrchestrationV2LimitRecoveryUpdate = Schema.Struct({ resetAt: IsoDateTime, autoResume: Schema.optional(Schema.Boolean), snooze: Schema.optional(Schema.Boolean), -}); +}).check( + Schema.makeFilter( + (update) => + update.autoResume !== undefined || + update.snooze !== undefined || + "A recovery update must include autoResume or snooze.", + ), +); export type OrchestrationV2LimitRecoveryUpdate = typeof OrchestrationV2LimitRecoveryUpdate.Type; export const OrchestrationV2AppThread = Schema.Struct({ From 5b7f2dbc0b72d7f44ff511f3f0110e6986f9826e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 18:01:27 -0700 Subject: [PATCH 09/10] perf(web): allow compiler optimization of limit banner --- apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index 56ea382954b60..9e2a809835b0e 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -70,9 +70,8 @@ function RecoveryActions({ runId, resetAt, recovery, snoozedUntil, onChange }: R }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); - } finally { - setPending(false); } + setPending(false); } return (

From 5f55f15f607535a3446abc9da5c0b8bdd1a5e424 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 20 Sep 2026 18:09:53 -0700 Subject: [PATCH 10/10] fix(server): validate recovery resets and preserve manual snoozes --- .../src/orchestration-v2/Orchestrator.ts | 11 +++---- .../src/orchestration-v2/runtimeLayer.test.ts | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c910143daf898..bd49052007b35 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2231,10 +2231,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* loadProjectionForCommand(command); const run = projection.runs.at(-1) ?? null; const failure = latestRootProviderFailure(run, projection.turnItems); - if ( - command.limitRecovery.snooze === true && - Date.parse(command.limitRecovery.resetAt) <= DateTime.toEpochMillis(now) - ) { + const resetMs = Date.parse(command.limitRecovery.resetAt); + if (command.limitRecovery.snooze === true && resetMs <= DateTime.toEpochMillis(now)) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -2242,13 +2240,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } if ( + !Number.isFinite(resetMs) || thread.archivedAt !== null || thread.settledOverride === "settled" || run?.id !== command.limitRecovery.runId || failure?.class !== "usage_limit" || failure.resetAt !== command.limitRecovery.resetAt || - Date.parse(command.limitRecovery.resetAt) <= - DateTime.toEpochMillis(run.completedAt ?? run.requestedAt) || + resetMs <= DateTime.toEpochMillis(run.completedAt ?? run.requestedAt) || projection.runtimeRequests.some((request) => request.status === "pending") || projection.runs.some((candidate) => candidate.status === "queued") ) { @@ -2344,6 +2342,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return { ...thread, snoozedUntil, + limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null, snoozedAt: existingSnoozedAt ?? now, updatedAt: existingSnoozedAt === null ? now : thread.updatedAt, }; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index c97d93345c625..e3992e725759d 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3027,6 +3027,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "settle", "replacement", "manual-snooze", + "manual-snooze-after-recovery", + "invalid-snooze", "snooze-only", "snooze-resume", "cancel-resume-keep-snooze", @@ -3082,10 +3084,13 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { const projection = yield* orchestrator.getThreadProjection(threadId); const run = projection.runs[0]!; const now = yield* DateTime.now; - const resetAt = DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace( - "Z", - scenario === "wake" ? "+00:00" : "Z", - ); + const resetAt = + scenario === "invalid-snooze" + ? "not-a-date" + : DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace( + "Z", + scenario === "wake" ? "+00:00" : "Z", + ); yield* events.write({ commandId: CommandId.make(`recovery:failure:${scenario}`), events: [ @@ -3132,8 +3137,24 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { (thread) => thread.id === threadId, )!; assert.isNull(limitRecoveryCommand(shell, false, DateTime.toEpochMillis(now))); + if (scenario === "invalid-snooze") { + const result = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:invalid-snooze"), + threadId, + limitRecovery: { runId: run.id, resetAt, snooze: true }, + }) + .pipe(Effect.exit); + assert.equal(result._tag, "Failure"); + const current = yield* orchestrator.getThreadProjection(threadId); + assert.isNull(current.thread.limitRecovery ?? null); + assert.isNull(current.thread.snoozedUntil); + return; + } const snooze = [ "snooze-only", + "manual-snooze-after-recovery", "snooze-resume", "wake", "cancel-resume-keep-snooze", @@ -3225,7 +3246,7 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { assert.isTrue(armedShell.limitRecovery!.autoResume); assert.isTrue(armedShell.limitRecovery!.snooze); } - if (scenario === "manual-snooze") { + if (scenario === "manual-snooze" || scenario === "manual-snooze-after-recovery") { yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make(`recovery:manual-snooze:${scenario}`),