diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx index 59210f74273f7..6d13e63e1f560 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx @@ -27,9 +27,10 @@ import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; const DAY_MS = 24 * 60 * 60 * 1000; -function failedThread(resetAt: string): EnvironmentThreadShell { +function failedThread(resetAt: string, settled = false): EnvironmentThreadShell { return { id: "thread-1", + settledOverride: settled ? "settled" : null, updatedAt: new Date(0).toISOString(), snoozedUntil: null, latestRun: { runId: "run-1", completedAt: new Date(0).toISOString() }, @@ -39,6 +40,24 @@ function failedThread(resetAt: string): EnvironmentThreadShell { } describe("UsageLimitRecoveryCard", () => { + it("offers no recovery on a settled thread and points at Un-settle", () => { + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers({ now: 0 }); + const container = document.createElement("div"); + const root = createRoot(container); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + act(() => + root.render( + , + ), + ); + expect(container.querySelectorAll("button")).toHaveLength(0); + expect(container.textContent).toContain("Un-settle to resume at reset."); + }); + it("disables Snooze once a reset more than 24.8 days away passes", () => { using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; vi.useFakeTimers({ now: 0 }); diff --git a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx index 791dc2ab5d847..c3847e75cdb22 100644 --- a/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx +++ b/apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx @@ -22,9 +22,12 @@ export function UsageLimitRecoveryCard({ const [pending, setPending] = useState(false); const [error, setError] = useState(null); const resetAt = thread.runtime?.usageLimitResetAt ?? null; - const canSchedule = + const resetsLater = resetAt !== null && Date.parse(resetAt) > Date.parse(thread.latestRun?.completedAt ?? thread.updatedAt); + // The server refuses recovery for settled threads until they are un-settled. + const settled = thread.settledOverride === "settled"; + const canSchedule = resetsLater && !settled; const resetPassed = useDeadlinePassed(resetAt === null ? null : Date.parse(resetAt)); const runId = thread.latestRun?.runId; const recovery = thread.limitRecovery; @@ -72,7 +75,9 @@ export function UsageLimitRecoveryCard({ {resetAt - ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.` + ? `Usage limit resets ${DateTime.toDateUtc(DateTime.makeUnsafe(resetAt)).toLocaleString()}.${ + resetsLater && settled ? " Un-settle to resume at reset." : "" + }` : "The provider did not report a reset time. Retry manually when your limit is available."} {canSchedule ? ( diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a6b876db1de96..8cec1f1469b95 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3493,10 +3493,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "The reset time has passed. Retry the thread manually.", }); } + if (thread.settledOverride === "settled") { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The thread is settled. Un-settle it to resume at the reset.", + }); + } if ( !Number.isFinite(resetMs) || thread.archivedAt !== null || - thread.settledOverride === "settled" || run?.id !== command.limitRecovery.runId || failure?.class !== "usage_limit" || failure.resetAt !== command.limitRecovery.resetAt || @@ -3597,6 +3603,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), + // Settled threads refuse recovery, so an armed resume would stay hidden until un-settled. + limitRecovery: thread.limitRecovery + ? { ...thread.limitRecovery, autoResume: false, snooze: false } + : thread.limitRecovery, pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), unsettledAt: null, pinnedAt: null, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 17b103f75b85f..37eb014466f34 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -8000,6 +8000,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { "replacement", "manual-snooze", "invalid-snooze", + "settled", "cancel-resume-keep-snooze", "independent-patches", "expired-snooze", @@ -8153,6 +8154,39 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isNull(current.thread.snoozedUntil); return; } + if (scenario === "settled") { + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:arm-before-settle:settled"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true, snooze: true }, + }); + yield* orchestrator.dispatch({ + type: "thread.settle", + commandId: CommandId.make("recovery:settle:settled"), + threadId, + }); + const error = yield* orchestrator + .dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("recovery:arm:settled"), + threadId, + limitRecovery: { runId: run.id, resetAt, autoResume: true }, + }) + .pipe(Effect.flip); + assert(error._tag === "OrchestratorDispatchError"); + assert.equal(error.cause, "The thread is settled. Un-settle it to resume at the reset."); + // Settling disarms the resume armed before it, so nothing is left pending out of sight. + assert.deepInclude( + (yield* orchestrator.getThreadProjection(threadId)).thread.limitRecovery, + { + runId: run.id, + autoResume: false, + snooze: false, + }, + ); + return; + } const snooze = scenario === "wake" || scenario === "cancel-resume-keep-snooze"; const autoResume = scenario !== "wake"; const arm = limitRecoveryCommand(shell, autoResume, DateTime.toEpochMillis(now), snooze); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 192505ea45408..65a06b7a44e63 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2541,6 +2541,7 @@ export default function ChatView(props: ChatViewProps) { errorCode: serverRuntime.lastErrorCode ?? null, resetAt: serverRuntime.usageLimitResetAt ?? null, stoppedAt: activeThreadShell.latestRun.completedAt ?? activeThreadShell.updatedAt, + settled: activeThreadShell.settledOverride === "settled", recovery: activeThreadShell.limitRecovery ?? null, snoozedUntil: activeThreadShell.snoozedUntil, timestampFormat, diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx index a4b534d66d1eb..820d7ed37afe4 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -6,17 +6,22 @@ import { usageLimitRecoveryBannerItem } from "./UsageLimitRecoveryBanner"; const stoppedAt = new Date(2026, 9, 20, 12, 0); -function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { +function banner(resetAt: Date, timestampFormat: TimestampFormat, settled = false) { return usageLimitRecoveryBannerItem({ runId: RunId.make("run-1"), errorCode: null, resetAt: resetAt.toISOString(), stoppedAt: stoppedAt.toISOString(), + settled, snoozedUntil: null, recovery: null, timestampFormat, onChange: async () => {}, - }).description; + }); +} + +function bannerDescription(resetAt: Date, timestampFormat: TimestampFormat) { + return banner(resetAt, timestampFormat).description; } describe("usageLimitRecoveryBannerItem", () => { @@ -28,6 +33,15 @@ describe("usageLimitRecoveryBannerItem", () => { expect(bannerDescription(new Date(2026, 9, 20, 17, 30), timestampFormat)).toBe(expected); }); + it("points a settled thread at Un-settle instead of offering recovery the server refuses", () => { + using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); + const resetAt = new Date(2026, 9, 20, 17, 30); + expect(banner(resetAt, "24-hour").actions).not.toBeNull(); + const settled = banner(resetAt, "24-hour", true); + expect(settled.actions).toBeNull(); + expect(settled.description).toBe("Resets 17:30. Un-settle to resume at reset"); + }); + it("names the day when the reset is tomorrow", () => { using _now = vi.spyOn(Date, "now").mockReturnValue(stoppedAt.getTime()); expect(bannerDescription(new Date(2026, 9, 21, 17, 30), "24-hour")).toBe( diff --git a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx index aabe7734a3329..8600132e370dc 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -25,6 +25,8 @@ type RecoveryProps = { errorCode: string | null; resetAt: string | null; stoppedAt: string; + /** The server refuses recovery for settled threads until they are un-settled. */ + settled: boolean; snoozedUntil: string | null; recovery: OrchestrationV2LimitRecovery | null; timestampFormat: TimestampFormat; @@ -32,15 +34,16 @@ type RecoveryProps = { }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { - const { runId, errorCode, resetAt, stoppedAt, timestampFormat } = props; - const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); + const { runId, errorCode, resetAt, stoppedAt, settled, timestampFormat } = props; + const resetsLater = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); const workspaceLimit = errorCode === CHATGPT_WORKSPACE_USAGE_LIMIT_CODE; const chatGptLimit = workspaceLimit || errorCode === CHATGPT_USAGE_LIMIT_CODE; - const recoveryActions = canSchedule ? ( - - ) : null; + const recoveryActions = + resetsLater && !settled ? : null; const resetDescription = resetAt - ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` + ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}${ + resetsLater && settled ? ". Un-settle to resume at reset" : "" + }` : "Reset time unavailable; retry manually"; if (!chatGptLimit) { return {