From f25ba0b1a18d101583b9c7c5bea4494a3616af76 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Sun, 1 Nov 2026 03:39:03 +0600 Subject: [PATCH 1/3] fix(clients): a settled usage-limited thread no longer offers recovery it refuses The server refuses usage-limit recovery for a settled thread, but the web banner and mobile card still offered Resume at reset and Snooze until reset. Every press failed with "The provider limit changed before recovery could be configured", which named the wrong cause. Both clients now hide those actions on a settled thread and say to un-settle it, and the server rejects that case with its own message. --- .../threads/UsageLimitRecoveryCard.test.tsx | 21 +++++++++++++++++- .../threads/UsageLimitRecoveryCard.tsx | 9 ++++++-- .../src/orchestration-v2/Orchestrator.ts | 8 ++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 22 +++++++++++++++++++ apps/web/src/components/ChatView.tsx | 1 + .../chat/UsageLimitRecoveryBanner.test.tsx | 18 +++++++++++++-- .../chat/UsageLimitRecoveryBanner.tsx | 13 +++++++---- 7 files changed, 82 insertions(+), 10 deletions(-) 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 0a5e56d98d639..9cbce85658803 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3304,10 +3304,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 || diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7b4a24219b2c1..e04897f2406ce 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7715,6 +7715,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { "replacement", "manual-snooze", "invalid-snooze", + "settled", "cancel-resume-keep-snooze", "independent-patches", "expired-snooze", @@ -7868,6 +7869,27 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.isNull(current.thread.snoozedUntil); return; } + if (scenario === "settled") { + 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."); + assert.isNull( + (yield* orchestrator.getThreadProjection(threadId)).thread.limitRecovery ?? null, + ); + 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 d2a176a444369..2a1fdb499fce8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2551,6 +2551,7 @@ export default function ChatView(props: ChatViewProps) { runId: activeThreadShell.latestRun.runId, 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 0938e7a3c6889..6014afbd9fd4e 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx @@ -6,16 +6,21 @@ 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"), 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", () => { @@ -27,6 +32,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 c99781738793d..12c6cffe7bc9d 100644 --- a/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx +++ b/apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx @@ -17,6 +17,8 @@ type RecoveryProps = { runId: RunId; 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; @@ -24,17 +26,20 @@ type RecoveryProps = { }; export function usageLimitRecoveryBannerItem(props: RecoveryProps): ComposerBannerStackItem { - const { runId, resetAt, stoppedAt, timestampFormat } = props; - const canSchedule = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); + const { runId, resetAt, stoppedAt, settled, timestampFormat } = props; + const resetsLater = resetAt !== null && Date.parse(resetAt) > Date.parse(stoppedAt); return { id: `usage-limit-recovery:${runId}`, variant: "warning", icon: , title: "Usage limit reached", description: resetAt - ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}` + ? `Resets ${formatUpcomingTimestamp(resetAt, timestampFormat)}${ + resetsLater && settled ? ". Un-settle to resume at reset" : "" + }` : "Reset time unavailable; retry manually", - actions: canSchedule ? : null, + actions: + resetsLater && !settled ? : null, }; } From db46ae1a137d95f80bda0ed82088bb085c718166 Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Sun, 1 Nov 2026 03:59:42 +0600 Subject: [PATCH 2/3] fix(server): settling a thread disarms its usage-limit recovery Settled threads refuse recovery and the clients now hide its controls, so an auto-resume armed before settling stayed pending out of sight and could start a run as soon as the thread was un-settled. Settling now disarms it, as stopping already does. --- apps/server/src/orchestration-v2/Orchestrator.ts | 4 ++++ .../src/orchestration-v2/runtimeLayer.test.ts | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 9cbce85658803..f293458fa9497 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3414,6 +3414,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 e04897f2406ce..06d427a5cb081 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7870,6 +7870,12 @@ it.layer(layerTest)("usage-limit recovery", (it) => { 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 }, + }); yield* orchestrator.dispatch({ type: "thread.settle", commandId: CommandId.make("recovery:settle:settled"), @@ -7885,8 +7891,14 @@ it.layer(layerTest)("usage-limit recovery", (it) => { .pipe(Effect.flip); assert(error._tag === "OrchestratorDispatchError"); assert.equal(error.cause, "The thread is settled. Un-settle it to resume at the reset."); - assert.isNull( - (yield* orchestrator.getThreadProjection(threadId)).thread.limitRecovery ?? null, + // 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; } From d4767821880d41c57bcfdb5c0a3c1e92d1eff02f Mon Sep 17 00:00:00 2001 From: Ehsanul Haque Siam Date: Sun, 1 Nov 2026 04:12:55 +0600 Subject: [PATCH 3/3] test(server): arm a recovery snooze before settling disarms it --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 06d427a5cb081..ebd61c847db99 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7874,7 +7874,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => { type: "thread.metadata.update", commandId: CommandId.make("recovery:arm-before-settle:settled"), threadId, - limitRecovery: { runId: run.id, resetAt, autoResume: true }, + limitRecovery: { runId: run.id, resetAt, autoResume: true, snooze: true }, }); yield* orchestrator.dispatch({ type: "thread.settle",