diff --git a/apps/web/src/components/chat/UsageLimitResumeBanner.tsx b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
new file mode 100644
index 000000000000..b62d33b53ba6
--- /dev/null
+++ b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
@@ -0,0 +1,55 @@
+import type { ClientSettings, EnvironmentId, ThreadId, ThreadUsageLimit } from "@t3tools/contracts";
+import { AlarmClockIcon } from "lucide-react";
+import { memo } from "react";
+
+import { useClientSettings } from "../../hooks/useSettings";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { formatShortTimestamp } from "../../timestampFormat";
+import { Alert, AlertAction, AlertDescription } from "../ui/alert";
+import { Button } from "../ui/button";
+
+const selectTimestampFormat = (settings: ClientSettings) => settings.timestampFormat;
+
+/** Offers, or shows, the server-side resume of a thread stopped by a usage limit. */
+export const UsageLimitResumeBanner = memo(function UsageLimitResumeBanner({
+ environmentId,
+ threadId,
+ usageLimit,
+}: {
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+ usageLimit: ThreadUsageLimit | null | undefined;
+}) {
+ const timestampFormat = useClientSettings(selectTimestampFormat);
+ const setAutoResume = useAtomCommand(threadEnvironment.setAutoResume);
+ if (!usageLimit?.resetsAt) return null;
+ const resetTime = formatShortTimestamp(usageLimit.resetsAt, timestampFormat);
+ const scheduled = usageLimit.resumeScheduled;
+ return (
+
+
+
+
+ {scheduled
+ ? `Resumes automatically at ${resetTime}.`
+ : `The usage limit resets at ${resetTime}.`}
+
+
+
+
+
+
+ );
+});
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index f37c9fb631c0..c5fc86d12ffd 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -605,6 +605,12 @@ export function useSettingsRestore(onRestored?: () => void) {
DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate
? ["Continue threads after restarts"]
: []),
+ ...(settings.autoResumeAfterUsageLimit !==
+ DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit ||
+ settings.autoResumeMessage !== DEFAULT_UNIFIED_SETTINGS.autoResumeMessage ||
+ settings.autoResumeDisablesFastMode !== DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode
+ ? ["Resume after usage limits"]
+ : []),
...(isBackgroundActivityDirty ? ["Background activity"] : []),
...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode
? ["New thread mode"]
@@ -676,6 +682,9 @@ export function useSettingsRestore(onRestored?: () => void) {
settings.responseStreamingMode,
settings.enableProviderUpdateChecks,
settings.continueThreadsAfterServerUpdate,
+ settings.autoResumeAfterUsageLimit,
+ settings.autoResumeMessage,
+ settings.autoResumeDisablesFastMode,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
settings.sidebarProjectGroupingMode,
@@ -782,6 +791,9 @@ export function useSettingsRestore(onRestored?: () => void) {
responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode,
enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks,
continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate,
+ autoResumeAfterUsageLimit: DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit,
+ autoResumeMessage: DEFAULT_UNIFIED_SETTINGS.autoResumeMessage,
+ autoResumeDisablesFastMode: DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode,
backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity,
backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile,
automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval,
@@ -2804,6 +2816,93 @@ export function GeneralSettingsPanel() {
}
/>
+
+ updateSettings({
+ autoResumeAfterUsageLimit: DEFAULT_UNIFIED_SETTINGS.autoResumeAfterUsageLimit,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ autoResumeAfterUsageLimit: Boolean(checked) })
+ }
+ aria-label="Resume after usage limits"
+ />
+ }
+ />
+
+
+ updateSettings({ autoResumeMessage: DEFAULT_UNIFIED_SETTINGS.autoResumeMessage })
+ }
+ />
+ ) : null
+ }
+ control={
+ updateSettings({ autoResumeMessage: next })}
+ placeholder={DEFAULT_UNIFIED_SETTINGS.autoResumeMessage}
+ aria-label="Resume message"
+ />
+ }
+ />
+
+
+ updateSettings({
+ autoResumeDisablesFastMode: DEFAULT_UNIFIED_SETTINGS.autoResumeDisablesFastMode,
+ })
+ }
+ />
+ ) : null
+ }
+ control={
+
+ updateSettings({ autoResumeDisablesFastMode: Boolean(checked) })
+ }
+ aria-label="Turn off fast mode when resuming"
+ />
+ }
+ />
+
;
export type UnsettleThreadInput = CommandInput<"thread.unsettle">;
export type SnoozeThreadInput = CommandInput<"thread.snooze">;
export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">;
+export type SetThreadAutoResumeInput = CommandInput<"thread.auto-resume.set">;
export type PinThreadInput = CommandInput<"thread.pin">;
export type UnpinThreadInput = CommandInput<"thread.unpin">;
export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">;
@@ -206,6 +207,16 @@ export const unsnoozeThread: (input: UnsnoozeThreadInput) => CommandEffect = Eff
});
});
+export const setThreadAutoResume: (input: SetThreadAutoResumeInput) => CommandEffect = Effect.fn(
+ "EnvironmentCommands.setThreadAutoResume",
+)(function* (input) {
+ return yield* dispatch({
+ ...input,
+ type: "thread.auto-resume.set",
+ commandId: yield* commandId(input),
+ });
+});
+
export const pinThread: (input: PinThreadInput) => CommandEffect = Effect.fn(
"EnvironmentCommands.pinThread",
)(function* (input) {
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index 93e22cfd0c70..5fbb786a03d7 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -24,6 +24,7 @@ import {
type RespondToThreadUserInputInput,
type DismissThreadUserInputInput,
type RevertThreadCheckpointInput,
+ type SetThreadAutoResumeInput,
type SetThreadInteractionModeInput,
type SetThreadRuntimeModeInput,
type PinThreadInput,
@@ -48,6 +49,7 @@ import {
respondToThreadUserInput,
dismissThreadUserInput,
revertThreadCheckpoint,
+ setThreadAutoResume,
setThreadInteractionMode,
setThreadRuntimeMode,
pinThread,
@@ -76,6 +78,7 @@ export type {
RespondToThreadUserInputInput,
DismissThreadUserInputInput,
RevertThreadCheckpointInput,
+ SetThreadAutoResumeInput,
SetThreadInteractionModeInput,
SetThreadRuntimeModeInput,
PinThreadInput,
@@ -152,6 +155,12 @@ export function createThreadEnvironmentAtoms(
scheduler,
concurrency,
}),
+ setAutoResume: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:set-auto-resume",
+ execute: (input: SetThreadAutoResumeInput) => setThreadAutoResume(input),
+ scheduler,
+ concurrency,
+ }),
pin: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:pin",
execute: (input: PinThreadInput) => pinThread(input),
diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts
index 379985b71243..35b8f109197b 100644
--- a/packages/client-runtime/src/state/threadDetail.ts
+++ b/packages/client-runtime/src/state/threadDetail.ts
@@ -62,6 +62,7 @@ export function mergeEnvironmentThread(
activeOrderKey: shell.activeOrderKey,
snoozedUntil: shell.snoozedUntil,
snoozedAt: shell.snoozedAt,
+ usageLimit: shell.usageLimit,
pinnedAt: shell.pinnedAt,
pinOrderKey: shell.pinOrderKey,
session: shell.session,
diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts
index 101bb34fba91..3d4ad2c7245d 100644
--- a/packages/client-runtime/src/state/threadReducer.ts
+++ b/packages/client-runtime/src/state/threadReducer.ts
@@ -133,6 +133,7 @@ export function applyThreadDetailEvent(
activeOrderKey: null,
snoozedUntil: null,
snoozedAt: null,
+ usageLimit: null,
deletedAt: null,
pullRequests: [],
messages: [],
@@ -215,6 +216,23 @@ export function applyThreadDetailEvent(
},
};
+ case "thread.usage-limit-set":
+ return {
+ kind: "updated",
+ thread: { ...thread, usageLimit: event.payload.usageLimit },
+ };
+
+ case "thread.auto-resume-set":
+ return thread.usageLimit
+ ? {
+ kind: "updated",
+ thread: {
+ ...thread,
+ usageLimit: { ...thread.usageLimit, resumeScheduled: event.payload.scheduled },
+ },
+ }
+ : { kind: "unchanged" };
+
case "thread.pinned":
return {
kind: "updated",
diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts
index 3e323e4964d5..de7322ed677a 100644
--- a/packages/contracts/src/orchestration.ts
+++ b/packages/contracts/src/orchestration.ts
@@ -790,6 +790,19 @@ export const ThreadPullRequestLink = Schema.Struct({
});
export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type;
+/**
+ * A provider usage limit stopped this thread's latest turn. `resetsAt` is null
+ * when the provider reported no reset time; such a stop cannot be resumed on a
+ * timer. `resumeScheduled` asks the server to send the configured resume
+ * message once `resetsAt` passes. Cleared by the next turn start.
+ */
+export const ThreadUsageLimit = Schema.Struct({
+ reachedAt: IsoDateTime,
+ resetsAt: Schema.NullOr(IsoDateTime),
+ resumeScheduled: Schema.Boolean,
+});
+export type ThreadUsageLimit = typeof ThreadUsageLimit.Type;
+
export const OrchestrationThread = Schema.Struct({
id: ThreadId,
projectId: ProjectId,
@@ -826,6 +839,8 @@ export const OrchestrationThread = Schema.Struct({
// Optional so payloads from pre-snooze servers still decode.
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ // Optional so payloads from pre-auto-resume servers still decode.
+ usageLimit: Schema.optional(Schema.NullOr(ThreadUsageLimit)),
// Active pinned threads render in the pinned block. Settled and snoozed
// threads remain in their respective shelves even when pinned.
// Optional so payloads from pre-pinning servers still decode.
@@ -905,6 +920,7 @@ export const OrchestrationThreadShell = Schema.Struct({
unsettledAt: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedUntil: Schema.optional(Schema.NullOr(IsoDateTime)),
snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
+ usageLimit: Schema.optional(Schema.NullOr(ThreadUsageLimit)),
pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)),
pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
@@ -1191,6 +1207,13 @@ const ThreadUnsnoozeCommand = Schema.Struct({
reason: Schema.Literal("user"),
});
+const ThreadAutoResumeSetCommand = Schema.Struct({
+ type: Schema.Literal("thread.auto-resume.set"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ scheduled: Schema.Boolean,
+});
+
const ThreadPinCommand = Schema.Struct({
type: Schema.Literal("thread.pin"),
commandId: CommandId,
@@ -1423,6 +1446,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
+ ThreadAutoResumeSetCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
@@ -1456,6 +1480,7 @@ export const ClientOrchestrationCommand = Schema.Union([
ThreadUnsettleCommand,
ThreadSnoozeCommand,
ThreadUnsnoozeCommand,
+ ThreadAutoResumeSetCommand,
ThreadPinCommand,
ThreadUnpinCommand,
ThreadPinReorderCommand,
@@ -1643,7 +1668,19 @@ const ThreadPullRequestLinkSyncCommand = Schema.Struct({
stack: Schema.NullOr(ThreadPullRequestStack),
});
+const ThreadUsageLimitSetCommand = Schema.Struct({
+ type: Schema.Literal("thread.usage-limit.set"),
+ commandId: CommandId,
+ threadId: ThreadId,
+ // Null records that the limit no longer blocks the thread.
+ usageLimit: Schema.NullOr(
+ Schema.Struct({ reachedAt: IsoDateTime, resetsAt: Schema.NullOr(IsoDateTime) }),
+ ),
+ createdAt: IsoDateTime,
+});
+
const InternalOrchestrationCommand = Schema.Union([
+ ThreadUsageLimitSetCommand,
ThreadAutoSettleCommand,
ThreadPullRequestSyncCommand,
ThreadPullRequestLinkSyncCommand,
@@ -1684,6 +1721,8 @@ export const OrchestrationEventType = Schema.Literals([
"thread.unsettled",
"thread.snoozed",
"thread.unsnoozed",
+ "thread.usage-limit-set",
+ "thread.auto-resume-set",
"thread.pinned",
"thread.unpinned",
"thread.pin-reordered",
@@ -1805,6 +1844,16 @@ export const ThreadUnsnoozedPayload = Schema.Struct({
updatedAt: IsoDateTime,
});
+export const ThreadUsageLimitSetPayload = Schema.Struct({
+ threadId: ThreadId,
+ usageLimit: Schema.NullOr(ThreadUsageLimit),
+});
+
+export const ThreadAutoResumeSetPayload = Schema.Struct({
+ threadId: ThreadId,
+ scheduled: Schema.Boolean,
+});
+
export const ThreadPinnedPayload = Schema.Struct({
threadId: ThreadId,
pinnedAt: IsoDateTime,
@@ -2074,6 +2123,16 @@ export const OrchestrationEvent = Schema.Union([
type: Schema.Literal("thread.unsnoozed"),
payload: ThreadUnsnoozedPayload,
}),
+ Schema.Struct({
+ ...EventBaseFields,
+ type: Schema.Literal("thread.usage-limit-set"),
+ payload: ThreadUsageLimitSetPayload,
+ }),
+ Schema.Struct({
+ ...EventBaseFields,
+ type: Schema.Literal("thread.auto-resume-set"),
+ payload: ThreadAutoResumeSetPayload,
+ }),
Schema.Struct({
...EventBaseFields,
type: Schema.Literal("thread.pinned"),
diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts
index 309b61935485..e86db5142ad3 100644
--- a/packages/contracts/src/providerRuntime.ts
+++ b/packages/contracts/src/providerRuntime.ts
@@ -196,6 +196,7 @@ const ConfigWarningType = Schema.Literal("config.warning");
const DeprecationNoticeType = Schema.Literal("deprecation.notice");
const FilesPersistedType = Schema.Literal("files.persisted");
const ToolDeniedType = Schema.Literal("tool.denied");
+const TurnUsageLimitedType = Schema.Literal("turn.usage-limited");
const RuntimeWarningType = Schema.Literal("runtime.warning");
const RuntimeErrorType = Schema.Literal("runtime.error");
@@ -801,6 +802,16 @@ const ToolDeniedPayload = Schema.Struct({
});
export type ToolDeniedPayload = typeof ToolDeniedPayload.Type;
+/**
+ * A provider usage limit stopped or parked the turn. Emitted alongside the
+ * adapter's own warning or failure text; `resetsAt` is omitted when the
+ * provider did not say when the limit resets.
+ */
+const TurnUsageLimitedPayload = Schema.Struct({
+ resetsAt: Schema.optional(IsoDateTime),
+});
+export type TurnUsageLimitedPayload = typeof TurnUsageLimitedPayload.Type;
+
const RuntimeWarningPayload = Schema.Struct({
message: TrimmedNonEmptyStringSchema,
detail: Schema.optional(Schema.Unknown),
@@ -1160,6 +1171,13 @@ const ProviderRuntimeToolDeniedEvent = Schema.Struct({
});
export type ProviderRuntimeToolDeniedEvent = typeof ProviderRuntimeToolDeniedEvent.Type;
+const ProviderRuntimeTurnUsageLimitedEvent = Schema.Struct({
+ ...ProviderRuntimeEventBase.fields,
+ type: TurnUsageLimitedType,
+ payload: TurnUsageLimitedPayload,
+});
+export type ProviderRuntimeTurnUsageLimitedEvent = typeof ProviderRuntimeTurnUsageLimitedEvent.Type;
+
const ProviderRuntimeWarningEvent = Schema.Struct({
...ProviderRuntimeEventBase.fields,
type: RuntimeWarningType,
@@ -1222,6 +1240,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([
ProviderRuntimeDeprecationNoticeEvent,
ProviderRuntimeFilesPersistedEvent,
ProviderRuntimeToolDeniedEvent,
+ ProviderRuntimeTurnUsageLimitedEvent,
ProviderRuntimeWarningEvent,
ProviderRuntimeErrorEvent,
]);
diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts
index 65aa113228fe..b2099e22c7ea 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1107,6 +1107,14 @@ export const ServerSettings = Schema.Struct({
continueThreadsAfterServerUpdate: Schema.Boolean.pipe(
Schema.withDecodingDefault(Effect.succeed(false)),
),
+ /**
+ * Resume a thread stopped by a provider usage limit once the limit resets,
+ * by sending `autoResumeMessage`. Threads can also opt in one stop at a time.
+ */
+ autoResumeAfterUsageLimit: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
+ autoResumeMessage: Schema.String.pipe(Schema.withDecodingDefault(Effect.succeed("go on"))),
+ /** Send the resume without fast mode; nobody is waiting on it. */
+ autoResumeDisablesFastMode: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
/**
* Whether agents may drive the in-app preview browser. Turning this off
* withholds the MCP credential, so the `t3-code` server (and with it every
@@ -1479,6 +1487,9 @@ export const ServerSettingsPatch = Schema.Struct({
responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
+ autoResumeAfterUsageLimit: Schema.optionalKey(Schema.Boolean),
+ autoResumeMessage: Schema.optionalKey(Schema.String),
+ autoResumeDisablesFastMode: Schema.optionalKey(Schema.Boolean),
enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean),
projectAgentBrowserAccessOverrides: Schema.optionalKey(
Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)),
From 1e669f1dc9391c30866967de983990b8672751d8 Mon Sep 17 00:00:00 2001
From: r4iju <5772718+r4iju@users.noreply.github.com>
Date: Thu, 24 Sep 2026 21:55:01 +0900
Subject: [PATCH 2/2] fix: keep resume choices and running turns intact
- Cancel sticks: only a new stop is auto-scheduled, not a re-report.
- A parked turn that carries on by itself clears its limit instead of
being interrupted by the resume.
- A turn parked on several windows waits for the last reset.
- Fast mode off is saved on the thread, not just the resumed turn.
- Reset times name the day when they are not today.
- The minute tick reads only threads with a pending resume.
---
.../features/threads/UsageLimitResumeCard.tsx | 22 +++++--
.../Layers/ProviderRuntimeIngestion.ts | 21 +++++--
.../UsageLimitResumePolicy.test.ts | 6 +-
.../orchestration/UsageLimitResumePolicy.ts | 5 +-
.../orchestration/UsageLimitResumeReactor.ts | 59 ++++++++++++++-----
.../chat/UsageLimitResumeBanner.tsx | 11 ++--
docs/user/usage.md | 3 +-
7 files changed, 92 insertions(+), 35 deletions(-)
diff --git a/apps/mobile/src/features/threads/UsageLimitResumeCard.tsx b/apps/mobile/src/features/threads/UsageLimitResumeCard.tsx
index 23cff8fc53c6..3f4c18bf8a60 100644
--- a/apps/mobile/src/features/threads/UsageLimitResumeCard.tsx
+++ b/apps/mobile/src/features/threads/UsageLimitResumeCard.tsx
@@ -10,6 +10,20 @@ const RESET_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
});
+const RESET_DAY_FORMATTER = new Intl.DateTimeFormat(undefined, {
+ weekday: "short",
+ month: "numeric",
+ day: "numeric",
+});
+
+/** Weekly limits reset days out, so anything past today names the day. */
+function formatReset(iso: string): string {
+ const date = new Date(iso);
+ const time = RESET_TIME_FORMATTER.format(date);
+ return date.toDateString() === new Date().toDateString()
+ ? `at ${time}`
+ : `${RESET_DAY_FORMATTER.format(date)} at ${time}`;
+}
/** Offers, or shows, the server-side resume of a thread stopped by a usage limit. */
export function UsageLimitResumeCard(props: {
@@ -18,17 +32,15 @@ export function UsageLimitResumeCard(props: {
readonly usageLimit: ThreadUsageLimit & { readonly resetsAt: string };
}) {
const setAutoResume = useAtomCommand(threadEnvironment.setAutoResume, "auto-resume update");
- const resetTime = RESET_TIME_FORMATTER.format(Date.parse(props.usageLimit.resetsAt));
+ const resetTime = formatReset(props.usageLimit.resetsAt);
const scheduled = props.usageLimit.resumeScheduled;
return (
- {scheduled
- ? `Resumes automatically at ${resetTime}.`
- : `The usage limit resets at ${resetTime}.`}
+ {scheduled ? `Resumes automatically ${resetTime}.` : `The usage limit resets ${resetTime}.`}
void setAutoResume({
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
index b8115a3ace56..3542b112f1b1 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
@@ -1758,6 +1758,9 @@ const make = Effect.gen(function* () {
},
);
+ const laterResetsAt = (current: string | null, next: string | null) =>
+ current !== null && (next === null || Date.parse(current) > Date.parse(next)) ? current : next;
+
const resolveUsageLimitResetsAt = (
event: Extract,
) =>
@@ -1960,17 +1963,23 @@ const make = Effect.gen(function* () {
threadId: thread.id,
usageLimit: {
reachedAt: thread.usageLimit?.reachedAt ?? now,
- resetsAt: yield* resolveUsageLimitResetsAt(event),
+ // A turn parked on several windows resumes only once the last one reopens.
+ resetsAt: laterResetsAt(
+ thread.usageLimit?.resetsAt ?? null,
+ yield* resolveUsageLimitResetsAt(event),
+ ),
},
createdAt: now,
});
} else if (
- event.type === "turn.completed" &&
- shouldApplyThreadLifecycle &&
- normalizeRuntimeTurnState(event.payload.state) === "completed" &&
- thread.usageLimit != null
+ thread.usageLimit != null &&
+ ((event.type === "content.delta" && event.payload.streamKind === "assistant_text") ||
+ (event.type === "turn.completed" &&
+ shouldApplyThreadLifecycle &&
+ normalizeRuntimeTurnState(event.payload.state) === "completed"))
) {
- // A parked turn the provider carried on by itself no longer needs a resume.
+ // A parked turn the provider carried on by itself no longer needs a resume,
+ // and must not be interrupted by one.
yield* orchestrationEngine.dispatch({
type: "thread.usage-limit.set",
commandId: yield* providerCommandId(event, "usage-limit-clear"),
diff --git a/apps/server/src/orchestration/UsageLimitResumePolicy.test.ts b/apps/server/src/orchestration/UsageLimitResumePolicy.test.ts
index 1559cec9c241..6807c152c667 100644
--- a/apps/server/src/orchestration/UsageLimitResumePolicy.test.ts
+++ b/apps/server/src/orchestration/UsageLimitResumePolicy.test.ts
@@ -138,9 +138,9 @@ describe("withoutFastMode", () => {
]);
});
- it("keeps other service tiers and selections without options", () => {
+ it("returns selections without fast mode unchanged", () => {
const flex: ModelSelection = { ...base, options: [{ id: "serviceTier", value: "flex" }] };
- expect(withoutFastMode(flex)).toEqual(flex);
- expect(withoutFastMode(base)).toEqual(base);
+ expect(withoutFastMode(flex)).toBe(flex);
+ expect(withoutFastMode(base)).toBe(base);
});
});
diff --git a/apps/server/src/orchestration/UsageLimitResumePolicy.ts b/apps/server/src/orchestration/UsageLimitResumePolicy.ts
index b1180c0d217f..bd1d06e33b9f 100644
--- a/apps/server/src/orchestration/UsageLimitResumePolicy.ts
+++ b/apps/server/src/orchestration/UsageLimitResumePolicy.ts
@@ -75,7 +75,10 @@ export function autoResumeText(configured: string): string {
/** Claude and Cursor use `fastMode`; Codex also expresses it as the `fast` service tier. */
export function withoutFastMode(selection: ModelSelection): ModelSelection {
- if (!selection.options) return selection;
+ const isFast = (option: NonNullable[number]) =>
+ (option.id === "fastMode" && option.value === true) ||
+ (option.id === "serviceTier" && option.value === "fast");
+ if (!selection.options?.some(isFast)) return selection;
return {
...selection,
options: selection.options.flatMap((option) => {
diff --git a/apps/server/src/orchestration/UsageLimitResumeReactor.ts b/apps/server/src/orchestration/UsageLimitResumeReactor.ts
index defb8fb8fbed..b5fe87689103 100644
--- a/apps/server/src/orchestration/UsageLimitResumeReactor.ts
+++ b/apps/server/src/orchestration/UsageLimitResumeReactor.ts
@@ -45,6 +45,9 @@ export const make = Effect.gen(function* () {
const crypto = yield* Crypto.Crypto;
// One interrupt per stop: a parked turn that ignores it is left to the user.
const interruptedStops = new Set();
+ // Filled from one full scan at start, then kept current from events, so the
+ // minute tick reads only threads with a pending resume.
+ const scheduledThreadIds = new Set();
const commandId = (tag: string, threadId: ThreadId) =>
Effect.map(crypto.randomUUIDv4, (uuid) =>
@@ -79,7 +82,10 @@ export const make = Effect.gen(function* () {
const resume = Effect.fn("UsageLimitResumeReactor.resume")(function* (threadId: ThreadId) {
const shell = yield* snapshots.getThreadShellById(threadId);
- if (Option.isNone(shell)) return;
+ if (Option.isNone(shell) || !shell.value.usageLimit?.resumeScheduled) {
+ scheduledThreadIds.delete(threadId);
+ return;
+ }
const thread = shell.value;
const now = DateTime.formatIso(yield* DateTime.now);
const action = resolveResumeAction(thread, now);
@@ -99,6 +105,18 @@ export const make = Effect.gen(function* () {
}
interruptedStops.delete(stopKey);
const settings = yield* settingsService.getSettings;
+ const modelSelection = settings.autoResumeDisablesFastMode
+ ? withoutFastMode(thread.modelSelection)
+ : thread.modelSelection;
+ if (modelSelection !== thread.modelSelection) {
+ // Saved on the thread so fast mode stays off after the resumed turn.
+ yield* engine.dispatch({
+ type: "thread.meta.update",
+ commandId: yield* commandId("fast-mode-off", threadId),
+ threadId,
+ modelSelection,
+ });
+ }
yield* engine.dispatch({
type: "thread.turn.start",
commandId: yield* commandId("send", threadId),
@@ -109,22 +127,22 @@ export const make = Effect.gen(function* () {
text: autoResumeText(settings.autoResumeMessage),
attachments: [],
},
- modelSelection: settings.autoResumeDisablesFastMode
- ? withoutFastMode(thread.modelSelection)
- : thread.modelSelection,
+ modelSelection,
runtimeMode: thread.runtimeMode,
interactionMode: thread.interactionMode,
createdAt: now,
});
});
- const sweep = Effect.fn("UsageLimitResumeReactor.sweep")(function* () {
+ const scan = Effect.fn("UsageLimitResumeReactor.scan")(function* () {
const snapshot = yield* snapshots.getShellSnapshot();
- yield* Effect.forEach(
- snapshot.threads.filter((thread) => thread.usageLimit?.resumeScheduled === true),
- (thread) => resume(thread.id),
- { discard: true },
- );
+ for (const thread of snapshot.threads) {
+ if (thread.usageLimit?.resumeScheduled) scheduledThreadIds.add(thread.id);
+ }
+ });
+
+ const sweep = Effect.fn("UsageLimitResumeReactor.sweep")(function* () {
+ yield* Effect.forEach([...scheduledThreadIds], resume, { discard: true });
});
type Job =
@@ -153,14 +171,22 @@ export const make = Effect.gen(function* () {
const processEvent = (event: OrchestrationEvent) => {
switch (event.type) {
case "thread.usage-limit-set":
- return event.payload.usageLimit !== null && !event.payload.usageLimit.resumeScheduled
+ // Re-reports of a stop keep its reachedAt, so only a new stop is
+ // auto-scheduled and a Cancel on the current one sticks.
+ return event.payload.usageLimit !== null &&
+ event.payload.usageLimit.reachedAt === event.occurredAt &&
+ !event.payload.usageLimit.resumeScheduled
? worker.enqueue({ kind: "auto-schedule", threadId: event.payload.threadId })
: Effect.void;
case "thread.auto-resume-set":
- return event.payload.scheduled
- ? worker.enqueue({ kind: "resume", threadId: event.payload.threadId })
- : Effect.void;
+ if (!event.payload.scheduled) {
+ scheduledThreadIds.delete(event.payload.threadId);
+ return Effect.void;
+ }
+ scheduledThreadIds.add(event.payload.threadId);
+ return worker.enqueue({ kind: "resume", threadId: event.payload.threadId });
case "thread.session-set":
+ if (!scheduledThreadIds.has(event.payload.threadId)) return Effect.void;
// The interrupted parked turn has settled; the resume can go out now.
return event.payload.session.status !== "running" &&
event.payload.session.status !== "starting"
@@ -174,6 +200,11 @@ export const make = Effect.gen(function* () {
"UsageLimitResumeReactor.start",
)(function* () {
const events = yield* engine.subscribeDomainEvents;
+ yield* scan().pipe(
+ Effect.catchCause((cause) =>
+ Effect.logWarning("usage limit resume scan failed", { cause: Cause.pretty(cause) }),
+ ),
+ );
yield* forkParked(
Effect.gen(function* () {
yield* worker.enqueue({ kind: "sweep" });
diff --git a/apps/web/src/components/chat/UsageLimitResumeBanner.tsx b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
index b62d33b53ba6..376dceae68cf 100644
--- a/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
+++ b/apps/web/src/components/chat/UsageLimitResumeBanner.tsx
@@ -5,7 +5,7 @@ import { memo } from "react";
import { useClientSettings } from "../../hooks/useSettings";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
-import { formatShortTimestamp } from "../../timestampFormat";
+import { formatUpcomingTimestamp } from "../../timestampFormat";
import { Alert, AlertAction, AlertDescription } from "../ui/alert";
import { Button } from "../ui/button";
@@ -24,7 +24,8 @@ export const UsageLimitResumeBanner = memo(function UsageLimitResumeBanner({
const timestampFormat = useClientSettings(selectTimestampFormat);
const setAutoResume = useAtomCommand(threadEnvironment.setAutoResume);
if (!usageLimit?.resetsAt) return null;
- const resetTime = formatShortTimestamp(usageLimit.resetsAt, timestampFormat);
+ const upcoming = formatUpcomingTimestamp(usageLimit.resetsAt, timestampFormat);
+ const resetTime = upcoming.startsWith("tomorrow") ? upcoming : `at ${upcoming}`;
const scheduled = usageLimit.resumeScheduled;
return (
@@ -32,8 +33,8 @@ export const UsageLimitResumeBanner = memo(function UsageLimitResumeBanner({
{scheduled
- ? `Resumes automatically at ${resetTime}.`
- : `The usage limit resets at ${resetTime}.`}
+ ? `Resumes automatically ${resetTime}.`
+ : `The usage limit resets ${resetTime}.`}
diff --git a/docs/user/usage.md b/docs/user/usage.md
index cd670663e4ce..213634bd4270 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -89,7 +89,8 @@ using a proxy through `ANTHROPIC_AUTH_TOKEN`.
When Claude, Codex, or Grok stops a thread on a usage limit and reports when it resets, the thread
offers **Resume at** that time. The server sends a short message a minute after the reset, so the
thread continues while you are away, even with every client closed. Choose **Cancel** to keep the
-thread stopped. Sending your own message clears the pending resume.
+thread stopped. Sending your own message clears the pending resume. Cursor, OpenCode, and
+Antigravity don't report usage limits, so their threads can't resume this way.
To schedule this for every limited thread, turn on **Settings → General → Resume after usage
limits**. The same page sets the message it sends (`go on` by default) and whether resumed turns