authorclover caruso <git@paperclover.net> 2026-10-15 14:58:49-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log6f7fee3541b9ef1fcedd996d055052a4be440bbb
treec88d659e8c62d2ba85ed1197269bcfa294ede75f
parent59d067bf9b4ea9cc94d4aeafeeba665cc3b06600
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix: keep usage-limit blocked agents as "working"

Clover wants chats that will resume automatically after a usage reset to stay out of the inbox until they need her. Classify a failed usage-limit run as Working only when its persisted automatic recovery matches the current run and reset time. Share the classification across web, desktop, and mobile. Keep manual or stale recovery, approvals, questions, and ready plans actionable, preserving pinned, snoozed, and settled placement. Assisted-by: gpt-6.1-sol

4 files changed, 134 insertions(+), 7 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+31
...@@ -2363,6 +2363,37 @@ describe("Working section beta", () => {...@@ -2363,6 +2363,37 @@ describe("Working section beta", () => {
2363 expect(off.workingCount).toBe(0);2363 expect(off.workingCount).toBe(0);
2364 });2364 });
23652365
2366 it.each([true, false])("classifies a Limited thread with auto-resume %s", (autoResume) => {
2367 const runId = RunId.make("limited-run");
2368 const resetAt = "2026-06-02T02:00:00.000Z";
2369 const limited = presentThreadShell(
2370 environmentId,
2371 makeRawThreadShell({
2372 id: ThreadId.make("limited"),
2373 latestRunId: runId,
2374 status: "failed",
2375 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-01T23:00:00.000Z"),
2376 lastError: "Plan limit reached",
2377 lastErrorClass: "usage_limit",
2378 usageLimitResetAt: resetAt,
2379 limitRecovery: { runId, resetAt, autoResume },
2380 }),
2381 );
2382 const layout = build({ threads: [limited] });
2383
2384 expect(layout.workingCount).toBe(autoResume ? 1 : 0);
2385 expect(ids(layout)).toEqual(autoResume ? [] : ["limited"]);
2386 expect(resolveThreadListV2Status(limited)).toBe("limited");
2387 expect(ids(build({ threads: [limited], workingShelfExpanded: true }))).toEqual(["limited"]);
2388 expect(ids(build({ threads: [limited], workingShelfEnabled: false }))).toEqual(["limited"]);
2389
2390 const snoozed = build({
2391 threads: [{ ...limited, snoozedUntil: resetAt, snoozedAt: NOW }],
2392 });
2393 expect(snoozed.snoozedCount).toBe(1);
2394 expect(snoozed.workingCount).toBe(0);
2395 });
2396
2366 it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {2397 it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {
2367 expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({2398 expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({
2368 thread: { id: "working" },2399 thread: { id: "working" },
apps/web/src/components/Sidebar.logic.test.ts+18-3
...@@ -37,7 +37,6 @@ import {...@@ -37,7 +37,6 @@ import {
37 resolveSidebarThreadStatus,37 resolveSidebarThreadStatus,
38 resolveSidebarV2TopStatus,38 resolveSidebarV2TopStatus,
39 resolveThreadLastVisitedAt,39 resolveThreadLastVisitedAt,
40 resolveThreadRowClassName,
41 resolveThreadStatusPill,40 resolveThreadStatusPill,
42 resolveWorkingStartedAt,41 resolveWorkingStartedAt,
43 pullRequestMatchesSearchQuery,42 pullRequestMatchesSearchQuery,
...@@ -53,7 +52,6 @@ import {...@@ -53,7 +52,6 @@ import {
53 sortProjectsForSidebar,52 sortProjectsForSidebar,
54 sortScopedProjectsForSidebar,53 sortScopedProjectsForSidebar,
55 sortSidebarV2ProjectGroups,54 sortSidebarV2ProjectGroups,
56 shouldCreateNewThreadInCurrentProject,
57 shouldNavigateAfterThreadPark,55 shouldNavigateAfterThreadPark,
58 THREAD_JUMP_HINT_SHOW_DELAY_MS,56 THREAD_JUMP_HINT_SHOW_DELAY_MS,
59 type SidebarListItem,57 type SidebarListItem,
...@@ -62,7 +60,6 @@ import {...@@ -62,7 +60,6 @@ import {
62 resolveSidebarDropVerb,60 resolveSidebarDropVerb,
63} from "./Sidebar.logic";61} from "./Sidebar.logic";
64import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";62import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
65import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
66import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";63import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
6764
68import {65import {
...@@ -2176,6 +2173,24 @@ describe("Working shelf (beta)", () => {...@@ -2176,6 +2173,24 @@ describe("Working shelf (beta)", () => {
2176 ).toBe(false);2173 ).toBe(false);
2177 });2174 });
21782175
2176 it.each([true, false])("classifies a Limited shell with auto-resume %s", (autoResume) => {
2177 const runId = RunId.make("limited-run");
2178 const resetAt = "2026-06-20T03:00:00.000Z";
2179 const thread = presentThreadShell(localEnvironmentId, {
2180 ...makeThreadFixture().source,
2181 latestRunId: runId,
2182 status: "failed",
2183 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
2184 lastError: "Plan limit reached",
2185 lastErrorClass: "usage_limit",
2186 usageLimitResetAt: resetAt,
2187 limitRecovery: { runId, resetAt, autoResume },
2188 });
2189
2190 expect(isSidebarThreadWorking(thread)).toBe(autoResume);
2191 expect(resolveSidebarThreadStatus(thread)).toBe("limited");
2192 });
2193
2179 describe("sortInboxThreadsByReturn", () => {2194 describe("sortInboxThreadsByReturn", () => {
2180 const thread = (2195 const thread = (
2181 id: string,2196 id: string,
packages/client-runtime/src/state/threadInbox.test.ts+69-1
...@@ -1,7 +1,11 @@...@@ -1,7 +1,11 @@
1import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";1import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { createInboxReturnTracker, sortWorkingThreadsBySend } from "./threadInbox.ts";4import {
5 createInboxReturnTracker,
6 isThreadWorking,
7 sortWorkingThreadsBySend,
8} from "./threadInbox.ts";
59
6const environmentId = EnvironmentId.make("environment-1");10const environmentId = EnvironmentId.make("environment-1");
711
...@@ -29,7 +33,71 @@ function thread(id: string, working: boolean) {...@@ -29,7 +33,71 @@ function thread(id: string, working: boolean) {
29 };33 };
30}34}
3135
36const limitedThread = {
37 ...thread("limited", false),
38 latestRun: {
39 runId: RunId.make("limited-run"),
40 status: "failed" as const,
41 requestedAt: "2026-06-01T00:00:00.000Z",
42 startedAt: "2026-06-01T00:00:00.000Z",
43 completedAt: "2026-06-01T01:00:00.000Z",
44 assistantMessageId: null,
45 },
46 runtime: {
47 status: "failed" as const,
48 activeRunId: null,
49 providerInstanceId: ProviderInstanceId.make("codex"),
50 providerName: "Codex",
51 lastError: "Plan limit reached",
52 lastErrorClass: "usage_limit" as const,
53 usageLimitResetAt: "2026-06-01T02:00:00.000Z",
54 updatedAt: "2026-06-01T01:00:00.000Z",
55 },
56 limitRecovery: {
57 runId: RunId.make("limited-run"),
58 resetAt: "2026-06-01T02:00:00.000Z",
59 autoResume: true,
60 },
61};
62
63describe("isThreadWorking usage-limit recovery", () => {
64 it("keeps a scheduled retry working until recovery changes, even after the reset time", () => {
65 expect(isThreadWorking(limitedThread)).toBe(true);
66 });
67
68 it.each([
69 { limitRecovery: null },
70 { limitRecovery: { ...limitedThread.limitRecovery, autoResume: false, snooze: true } },
71 { limitRecovery: { ...limitedThread.limitRecovery, runId: RunId.make("previous-run") } },
72 { limitRecovery: { ...limitedThread.limitRecovery, resetAt: "2026-06-02T02:00:00.000Z" } },
73 { runtime: { ...limitedThread.runtime, lastErrorClass: "provider_error" as const } },
74 { runtime: { ...limitedThread.runtime, usageLimitResetAt: null } },
75 { runtime: { ...limitedThread.runtime, status: "completed" as const } },
76 { latestRun: null },
77 { hasPendingApprovals: true },
78 { hasPendingUserInput: true },
79 { pendingSubagentRequest: "approval" as const },
80 { interactionMode: "plan" as const, hasActionableProposedPlan: true },
81 ])("keeps actionable or obsolete recovery in the inbox (%j)", (override) => {
82 expect(isThreadWorking({ ...limitedThread, ...override })).toBe(false);
83 });
84});
85
32describe("createInboxReturnTracker", () => {86describe("createInboxReturnTracker", () => {
87 it("keeps an automatic limit wait out of inbox returns until auto-resume is cancelled", () => {
88 const tracker = createInboxReturnTracker();
89 tracker.observe([
90 { ...limitedThread, runtime: { ...limitedThread.runtime, status: "running" } },
91 ]);
92 tracker.observe([limitedThread]);
93 expect(tracker.returnedAt(limitedThread)).toBeUndefined();
94
95 tracker.observe([
96 { ...limitedThread, limitRecovery: { ...limitedThread.limitRecovery, autoResume: false } },
97 ]);
98 expect(tracker.returnedAt(limitedThread)).toBeDefined();
99 });
100
33 it("stamps a thread when it stops working, but never on the first observation", () => {101 it("stamps a thread when it stops working, but never on the first observation", () => {
34 const tracker = createInboxReturnTracker();102 const tracker = createInboxReturnTracker();
35 tracker.observe([thread("a", true), thread("b", false)]);103 tracker.observe([thread("a", true), thread("b", false)]);
packages/client-runtime/src/state/threadInbox.ts+16-3
...@@ -11,18 +11,31 @@ type WorkingThreadInput = Pick<...@@ -11,18 +11,31 @@ type WorkingThreadInput = Pick<
11 | "hasPendingUserInput"11 | "hasPendingUserInput"
12 | "pendingSubagentRequest"12 | "pendingSubagentRequest"
13 | "interactionMode"13 | "interactionMode"
14 | "limitRecovery"
14 | "latestRun"15 | "latestRun"
15 | "runtime"16 | "runtime"
16>;17>;
1718
18/** Threads busy with work that does not need the user fold into the Working19/** Threads busy with work that does not need the user fold into the Working
19 section: a running run, or one stopped with background work that will wake20 section: a running run, or one stopped with background work that will wake
20 it. Approvals, questions (its own or a subagent's), plan prompts, and21 it, including scheduled usage-limit recovery. Approvals, questions (its
21 failures stay in the inbox. */22 own or a subagent's), plan prompts, and other failures stay in the inbox. */
22export function isThreadWorking(thread: WorkingThreadInput): boolean {23export function isThreadWorking(thread: WorkingThreadInput): boolean {
23 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;24 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
24 if (thread.pendingSubagentRequest != null) return false;25 if (thread.pendingSubagentRequest != null) return false;
25 if (!threadRuntimeIsActive(thread.runtime) && thread.runtime?.status !== "idle") return false;26 const recovery = thread.limitRecovery;
27 const resumingAtUsageReset =
28 thread.runtime?.status === "failed" &&
29 thread.runtime.lastErrorClass === "usage_limit" &&
30 recovery?.autoResume === true &&
31 recovery.runId === thread.latestRun?.runId &&
32 recovery.resetAt === thread.runtime.usageLimitResetAt;
33 if (
34 !threadRuntimeIsActive(thread.runtime) &&
35 thread.runtime?.status !== "idle" &&
36 !resumingAtUsageReset
37 )
38 return false;
26 // A plan prompt outranks lingering background work: the user has to act on it.39 // A plan prompt outranks lingering background work: the user has to act on it.
27 const run = thread.latestRun;40 const run = thread.latestRun;
28 const runSettled =41 const runSettled =