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..0b902e25db60e 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -32,14 +32,32 @@ export function UsageLimitRecoveryCard({ !runId ) return null; - async function toggle() { + const snoozed = + recovery?.snooze === true && + recovery.runId === runId && + recovery.resetAt === resetAt && + resetAt !== null && + thread.snoozedUntil !== null && + 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 { const result = await updateMetadata({ environmentId, - input: { threadId: thread.id, limitRecovery: { runId, resetAt, autoResume: !scheduled } }, + input: { + threadId: thread.id, + limitRecovery: { + runId, + resetAt, + ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }), + }, + }, }); if (result._tag === "Failure") throw squashAtomCommandFailure(result); } catch (cause) { @@ -56,16 +74,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..bd49052007b35 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2231,14 +2231,22 @@ 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); + 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, + cause: "The reset time has passed. Retry the thread manually.", + }); + } 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") ) { @@ -2334,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, }; @@ -2402,18 +2411,44 @@ 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 === undefined ? {} : { limitRecovery }), + ...(command.limitRecovery !== undefined && + limitRecovery?.snooze === true && + Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) + ? { + 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, + } + : command.limitRecovery !== undefined && + thread.limitRecovery?.snooze && + thread.snoozedUntil != null && + DateTime.toEpochMillis(thread.snoozedUntil) === + Date.parse(thread.limitRecovery.resetAt) + ? { snoozedUntil: null, snoozedAt: null } + : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), ...(command.linkedPullRequest === undefined @@ -2461,6 +2496,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/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..e3992e725759d 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3026,6 +3026,16 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { "archive", "settle", "replacement", + "manual-snooze", + "manual-snooze-after-recovery", + "invalid-snooze", + "snooze-only", + "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* () { const orchestrator = yield* OrchestratorV2; @@ -3074,7 +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 })); + 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: [ @@ -3121,18 +3137,151 @@ 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)); + 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", + "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, { runId: run.id, resetAt, - autoResume: true, + autoResume, + snooze, requestId: arm!.commandId, }); + 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 === "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" || scenario === "manual-snooze-after-recovery") { + 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, + reason: "user", + }); + assert.isNull((yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntil); + } + 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 +3302,8 @@ it.layer(TestLayer)("usage-limit recovery", (it) => { true, DateTime.toEpochMillis(yield* DateTime.now), ); - 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 })); yield* orchestrator.dispatch({ @@ -3179,6 +3329,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", @@ -3259,11 +3425,33 @@ 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" || + scenario === "wake-preserve-resume" || + scenario === "independent-patches" || + scenario === "expired-snooze" + ? 1 + : 0), + ); + assert.lengthOf( + after.messages, + before.messages.length + + (scenario === "resume" || + scenario === "snooze-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 c6b1fb81f7fb0..efaa90d4a44e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6614,10 +6614,24 @@ 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 && + 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 +6645,7 @@ export default function ChatView(props: ChatViewProps) { } finally { setUnsnoozingThreadKey((current) => (current === threadKey ? null : current)); } - }, [activeThreadRef, unsnoozeThreadMutation]); + }, [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 @@ -6983,6 +6997,7 @@ export default function ChatView(props: ChatViewProps) { resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, recovery: activeThreadShell.limitRecovery ?? null, + snoozedUntil: activeThreadShell.snoozedUntil, onChange: async (limitRecovery) => { const result = await updateThreadMetadata({ environmentId, @@ -7066,6 +7081,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 2d2992cc092ad..9e2a809835b0e 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"; @@ -8,8 +12,9 @@ type RecoveryProps = { runId: RunId; resetAt: string | null; stoppedAt: string; + snoozedUntil: string | null; recovery: OrchestrationV2LimitRecovery | null; - onChange: (recovery: OrchestrationV2LimitRecovery) => Promise; + onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise; }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { @@ -24,32 +29,65 @@ 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, 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; - 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; + 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: !scheduled }); + await onChange({ + runId, + resetAt, + ...(action === "resume" ? { autoResume: !scheduled } : { snooze: !snoozed }), + }); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not change limit recovery."); - } finally { - setPending(false); } + setPending(false); } return (
- + {!snoozed ? ( + + ) : null} {error ? (

{error} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 52e6103fc96ab..6536c4e904b9a 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.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 1c62d140c247c..f6218f1899b15 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -331,9 +331,26 @@ export const OrchestrationV2LimitRecovery = Schema.Struct({ runId: RunId, resetAt: IsoDateTime, autoResume: Schema.Boolean, + snooze: Schema.optional(Schema.Boolean), }); 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), +}).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({ ...OrchestrationV2CreationFields, id: ThreadId, @@ -2339,7 +2356,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)), }), 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)),