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", () => {
23632363 expect(off.workingCount).toBe(0);
23642364 });
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
23662397 it("shows working rows as cards when expanded, or only the selected one when collapsed", () => {
23672398 expect(build({ workingShelfExpanded: true }).items.at(-1)).toMatchObject({
23682399 thread: { id: "working" },
apps/web/src/components/Sidebar.logic.test.ts+18-3
......@@ -37,7 +37,6 @@ import {
3737 resolveSidebarThreadStatus,
3838 resolveSidebarV2TopStatus,
3939 resolveThreadLastVisitedAt,
40 resolveThreadRowClassName,
4140 resolveThreadStatusPill,
4241 resolveWorkingStartedAt,
4342 pullRequestMatchesSearchQuery,
......@@ -53,7 +52,6 @@ import {
5352 sortProjectsForSidebar,
5453 sortScopedProjectsForSidebar,
5554 sortSidebarV2ProjectGroups,
56 shouldCreateNewThreadInCurrentProject,
5755 shouldNavigateAfterThreadPark,
5856 THREAD_JUMP_HINT_SHOW_DELAY_MS,
5957 type SidebarListItem,
......@@ -62,7 +60,6 @@ import {
6260 resolveSidebarDropVerb,
6361} from "./Sidebar.logic";
6462import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
65import { sortSettledThreads } from "@t3tools/client-runtime/state/thread-sort";
6663import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
6764
6865import {
......@@ -2176,6 +2173,24 @@ describe("Working shelf (beta)", () => {
21762173 ).toBe(false);
21772174 });
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
21792194 describe("sortInboxThreadsByReturn", () => {
21802195 const thread = (
21812196 id: string,
packages/client-runtime/src/state/threadInbox.test.ts+69-1
......@@ -1,7 +1,11 @@
11import { EnvironmentId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts";
22import { 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
610const environmentId = EnvironmentId.make("environment-1");
711
......@@ -29,7 +33,71 @@ function thread(id: string, working: boolean) {
2933 };
3034}
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
3286describe("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
33101 it("stamps a thread when it stops working, but never on the first observation", () => {
34102 const tracker = createInboxReturnTracker();
35103 tracker.observe([thread("a", true), thread("b", false)]);
packages/client-runtime/src/state/threadInbox.ts+16-3
......@@ -11,18 +11,31 @@ type WorkingThreadInput = Pick<
1111 | "hasPendingUserInput"
1212 | "pendingSubagentRequest"
1313 | "interactionMode"
14 | "limitRecovery"
1415 | "latestRun"
1516 | "runtime"
1617>;
1718
1819/** Threads busy with work that does not need the user fold into the Working
1920 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, and
21 failures stay in the inbox. */
21 it, including scheduled usage-limit recovery. Approvals, questions (its
22 own or a subagent's), plan prompts, and other failures stay in the inbox. */
2223export function isThreadWorking(thread: WorkingThreadInput): boolean {
2324 if (thread.hasPendingApprovals || thread.hasPendingUserInput) return false;
2425 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;
2639 // A plan prompt outranks lingering background work: the user has to act on it.
2740 const run = thread.latestRun;
2841 const runSettled =