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 {