Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion apps/mobile/src/features/threads/UsageLimitRecoveryCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -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(
<UsageLimitRecoveryCard
thread={failedThread(new Date(DAY_MS).toISOString(), true)}
environmentId={"env-1" as EnvironmentId}
/>,
),
);
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 });
Expand Down
9 changes: 7 additions & 2 deletions apps/mobile/src/features/threads/UsageLimitRecoveryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ export function UsageLimitRecoveryCard({
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(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;
Expand Down Expand Up @@ -72,7 +75,9 @@ export function UsageLimitRecoveryCard({
<View className="mx-3 mb-2 gap-2 rounded-xl border border-warning-foreground/25 bg-screen p-3">
<Text className="text-sm text-warning-foreground">
{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."}
</Text>
{canSchedule ? (
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 ||
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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(
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/components/chat/UsageLimitRecoveryBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,25 @@ 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;
onChange: (recovery: OrchestrationV2LimitRecoveryUpdate) => Promise<void>;
};

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 ? (
<RecoveryActions key={`${runId}:${resetAt}`} {...props} />
) : null;
const recoveryActions =
resetsLater && !settled ? <RecoveryActions key={`${runId}:${resetAt}`} {...props} /> : null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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 {
Expand Down
Loading