authorgravatar for 56691628+Mnigos@users.noreply.github.comIgor Makowski <56691628+Mnigos@users.noreply.github.com> 2026-10-03 11:12:28+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 02:12:28-07:00
logf68e24fb419c985269cd0acb841e203af1419c43
tree0b7e69fcdbbe758e1b8e7619fe9ca8e92f5866ef
parenta5b34b25378fbbf90ce36ee101a11528ccf11c7e
signature Signed by PGP key B5690EEEBB952194

fix(web): a thread that left a shell running shows its unseen completion (#14910)

Co-authored-by: Theo Browne <me@t3.gg>

8 files changed, 204 insertions(+), 36 deletions(-)

apps/mobile/src/features/threads/threadListV2.test.ts+27-4
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
1import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";3import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
2import {4import {
3 createPendingThreadOrder,5 createPendingThreadOrder,
...@@ -23,7 +25,7 @@ import {...@@ -23,7 +25,7 @@ import {
23import { describe, expect, it, vi } from "vite-plus/test";25import { describe, expect, it, vi } from "vite-plus/test";
2426
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";27import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import { makeThreadShellFixture } from "../../test-fixtures";28import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures";
27import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";29import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
28import {30import {
29 buildThreadListV2Items,31 buildThreadListV2Items,
...@@ -36,6 +38,7 @@ import {...@@ -36,6 +38,7 @@ import {
36 resolveThreadListV2SwipeActions,38 resolveThreadListV2SwipeActions,
37 sortThreadsForListV2,39 sortThreadsForListV2,
38 threadListV2ListItemsAreEqual,40 threadListV2ListItemsAreEqual,
41 threadHasUnseenCompletion,
39 type ThreadListV2ListItem,42 type ThreadListV2ListItem,
40} from "./threadListV2";43} from "./threadListV2";
4144
...@@ -160,9 +163,7 @@ describe("resolveThreadListV2Status", () => {...@@ -160,9 +163,7 @@ describe("resolveThreadListV2Status", () => {
160 makeThread({163 makeThread({
161 id: ThreadId.make("t"),164 id: ThreadId.make("t"),
162 title: "t",165 title: "t",
163 pendingBackgroundTasks: [166 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
164 { taskId: "bg-1", description: "Run Codex review", kind: "command" },
165 ],
166 runtime: {167 runtime: {
167 status: "idle",168 status: "idle",
168 activeRunId: null,169 activeRunId: null,
...@@ -176,6 +177,28 @@ describe("resolveThreadListV2Status", () => {...@@ -176,6 +177,28 @@ describe("resolveThreadListV2Status", () => {
176 ).toBe("waiting");177 ).toBe("waiting");
177 });178 });
178179
180 it.each([
181 { kind: "command", status: "ready" },
182 { kind: "monitor", status: "waiting" },
183 ] as const)(
184 "presents an unseen completion with a $kind roster as $status",
185 ({ kind, status }) => {
186 const thread = presentThreadShell(
187 environmentId,
188 makeRawThreadShell({
189 latestRunId: RunId.make("run-background-completion"),
190 status: "completed",
191 latestRunCompletedAt: DateTime.makeUnsafe(NOW),
192 lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"),
193 pendingBackgroundTasks: [{ taskId: "background-work", kind }],
194 }),
195 );
196
197 expect(resolveThreadListV2Status(thread)).toBe(status);
198 expect(threadHasUnseenCompletion(thread)).toBe(true);
199 },
200 );
201
179 it("resolves ready for quiescent threads", () => {202 it("resolves ready for quiescent threads", () => {
180 expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(203 expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
181 "ready",204 "ready",
apps/mobile/src/features/threads/threadListV2.ts+2-1
...@@ -62,7 +62,8 @@ export function resolveThreadListV2ProviderDrivers(...@@ -62,7 +62,8 @@ export function resolveThreadListV2ProviderDrivers(
62 * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent62 * failures. Ready is the unlabeled resting state; waiting (runtime status "idle") is the agent
63 * parked on open background tasks, grey like working rather than a false Done.63 * parked on open background tasks, grey like working rather than a false Done.
64 * The orchestrator v2 presentation bridge parks runtime at idle when the64 * The orchestrator v2 presentation bridge parks runtime at idle when the
65 * post-settlement background roster is nonempty.65 * post-settlement background roster holds the run's completion (subagents,
66 * monitors); commands left running, such as a dev server, read as ready.
66 */67 */
67export type ThreadListV2Status =68export type ThreadListV2Status =
68 | "approval"69 | "approval"
apps/web/src/components/Sidebar.logic.test.ts+37-2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import * as DateTime from "effect/DateTime";
1import { deriveActiveWorkStartedAt } from "../session-logic.ts";3import { deriveActiveWorkStartedAt } from "../session-logic.ts";
2import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";4import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
3import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";5import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
...@@ -1254,7 +1256,7 @@ describe("resolveThreadStatusPill", () => {...@@ -1254,7 +1256,7 @@ describe("resolveThreadStatusPill", () => {
1254 resolveThreadStatusPill({1256 resolveThreadStatusPill({
1255 thread: {1257 thread: {
1256 ...baseThread,1258 ...baseThread,
1257 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],1259 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
1258 runtime: {1260 runtime: {
1259 ...baseThread.runtime,1261 ...baseThread.runtime,
1260 status: "idle",1262 status: "idle",
...@@ -2053,6 +2055,39 @@ describe("navigation after parking a thread", () => {...@@ -2053,6 +2055,39 @@ describe("navigation after parking a thread", () => {
2053 );2055 );
2054});2056});
20552057
2058describe("unseen completion with background work", () => {
2059 it.each([
2060 { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" },
2061 { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" },
2062 ] as const)("presents a completed thread with a $kind roster", (expected) => {
2063 const thread = presentThreadShell(localEnvironmentId, {
2064 ...makeThreadFixture().source,
2065 latestRunId: RunId.make("run-background-completion"),
2066 status: "completed",
2067 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"),
2068 lastVisitedAt: DateTime.makeUnsafe("2026-06-20T00:59:00.000Z"),
2069 pendingBackgroundTasks: [{ taskId: "background-work", kind: expected.kind }],
2070 });
2071 const status = resolveSidebarThreadStatus(thread);
2072 const isUnread = hasUnseenCompletion(thread);
2073
2074 expect(isUnread).toBe(true);
2075 expect(status).toBe(expected.status);
2076 expect(resolveSidebarV2TopStatus({ status, isUnread, isWoke: false })).toBe(expected.topStatus);
2077 expect(
2078 shouldRecedeSidebarThread({
2079 status,
2080 isUnread,
2081 isWoke: false,
2082 isActive: false,
2083 isSelected: false,
2084 }),
2085 ).toBe(expected.receded);
2086 expect(isSidebarThreadWorking(thread)).toBe(expected.receded);
2087 expect(resolveThreadStatusPill({ thread })).toMatchObject({ label: expected.pill });
2088 });
2089});
2090
2056describe("Working shelf (beta)", () => {2091describe("Working shelf (beta)", () => {
2057 const runtime = {2092 const runtime = {
2058 status: "running" as const,2093 status: "running" as const,
...@@ -2062,7 +2097,7 @@ describe("Working shelf (beta)", () => {...@@ -2062,7 +2097,7 @@ describe("Working shelf (beta)", () => {
2062 lastError: null,2097 lastError: null,
2063 updatedAt: "2026-03-09T10:00:00.000Z",2098 updatedAt: "2026-03-09T10:00:00.000Z",
2064 };2099 };
2065 const backgroundTask = { taskId: "bg-1", description: "sleep 20", kind: "command" as const };2100 const backgroundTask = { taskId: "bg-1", description: "Watch build", kind: "monitor" as const };
2066 const idle = {2101 const idle = {
2067 hasActionableProposedPlan: false,2102 hasActionableProposedPlan: false,
2068 hasPendingApprovals: false,2103 hasPendingApprovals: false,
apps/web/src/components/Sidebar.logic.ts+8-6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";1import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models";
2import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
2import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";3import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
3import * as React from "react";4import * as React from "react";
4import {5import {
...@@ -910,9 +911,10 @@ export function resolveThreadRowClassName(input: {...@@ -910,9 +911,10 @@ export function resolveThreadRowClassName(input: {
910// (approval), "in motion" (working), and "broken" (failed). Ready is the911// (approval), "in motion" (working), and "broken" (failed). Ready is the
911// unlabeled resting state — the agent stopped and is waiting on the user,912// unlabeled resting state — the agent stopped and is waiting on the user,
912// whether it finished, asked a question, or proposed a plan. Waiting913// whether it finished, asked a question, or proposed a plan. Waiting
913// (runtime status "idle") is the agent stopped with background tasks still914// (runtime status "idle") is the agent stopped with background work that will
914// open: not the user's turn yet, so it renders grey like working, not as a915// wake it (subagents, monitors): not the user's turn yet, so it renders grey
915// false Done.916// like working, not as a false Done. Commands it left running, such as a dev
917// server, do not hold the thread; it reads as ready.
916// Unread completion is tracked separately: it describes whether a ready918// Unread completion is tracked separately: it describes whether a ready
917// thread needs attention, not what the thread is currently doing.919// thread needs attention, not what the thread is currently doing.
918export type SidebarThreadStatus =920export type SidebarThreadStatus =
...@@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea...@@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea
1007}1009}
10081010
1009/** Working beta: threads busy with work that does not need the user fold into1011/** Working beta: threads busy with work that does not need the user fold into
1010 the Working shelf: a running run, or one stopped with background tasks1012 the Working shelf: a running run, or one stopped with background work
1011 still open. Approvals, questions, plan prompts, and failures stay in the1013 that will wake it. Approvals, questions, plan prompts, and failures stay in the
1012 inbox. */1014 inbox. */
1013export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {1015export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean {
1014 const status = resolveSidebarThreadStatus(thread);1016 const status = resolveSidebarThreadStatus(thread);
...@@ -1209,7 +1211,7 @@ export function resolveThreadStatusPill(input: {...@@ -1209,7 +1211,7 @@ export function resolveThreadStatusPill(input: {
1209 };1211 };
1210 }1212 }
12111213
1212 if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) {1214 if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) {
1213 return {1215 return {
1214 label: "Waiting",1216 label: "Waiting",
1215 colorClass: "text-sidebar-muted-foreground",1217 colorClass: "text-sidebar-muted-foreground",
apps/web/src/components/ThreadNotificationCoordinator.tsx+4-6
...@@ -2,7 +2,6 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models";...@@ -2,7 +2,6 @@ import { presentThreadShell } from "@t3tools/client-runtime/state/models";
2import { useAtomValue } from "@effect/atom-react";2import { useAtomValue } from "@effect/atom-react";
3import { useNavigate, useParams } from "@tanstack/react-router";3import { useNavigate, useParams } from "@tanstack/react-router";
4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";4import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
5import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
6import * as Option from "effect/Option";5import * as Option from "effect/Option";
7import {6import {
8 CircleAlertIcon,7 CircleAlertIcon,
...@@ -126,12 +125,11 @@ function EnvironmentNotifications({...@@ -126,12 +125,11 @@ function EnvironmentNotifications({
126 ? `${thread.latestRun?.runId ?? ""}:${status}`125 ? `${thread.latestRun?.runId ?? ""}:${status}`
127 : null;126 : null;
128 const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");127 const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");
129 // Waiting only on commands (a dev server) is done; subagents and monitors wake the agent.128 // Commands left running (a dev server) read as ready; subagents and monitors wait.
130 const settled =
131 status === "ready" ||
132 (status === "waiting" && !backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks));
133 const completion =129 const completion =
134 settled && thread.latestRun?.status === "completed" && Number.isFinite(completedAt)130 status === "ready" &&
131 thread.latestRun?.status === "completed" &&
132 Number.isFinite(completedAt)
135 ? completedAt133 ? completedAt
136 : (prior?.completion ?? null);134 : (prior?.completion ?? null);
137 next.set(thread.id, { attention, completion });135 next.set(thread.id, { attention, completion });
packages/client-runtime/src/state/entities.test.ts+112-11
...@@ -2,8 +2,10 @@ import {...@@ -2,8 +2,10 @@ import {
2 EnvironmentId,2 EnvironmentId,
3 MessageId,3 MessageId,
4 NodeId,4 NodeId,
5 ProviderDriverKind,
5 ProviderInstanceId,6 ProviderInstanceId,
6 ProviderSessionId,7 ProviderSessionId,
8 ProviderThreadId,
7 RunId,9 RunId,
8 RuntimeRequestId,10 RuntimeRequestId,
9 TurnItemId,11 TurnItemId,
...@@ -140,7 +142,7 @@ describe("V2 client presentation", () => {...@@ -140,7 +142,7 @@ describe("V2 client presentation", () => {
140 latestRunId: runId,142 latestRunId: runId,
141 activeRunId: null,143 activeRunId: null,
142 status: "completed",144 status: "completed",
143 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],145 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
144 });146 });
145147
146 expect(shell.latestRun).toMatchObject({ runId, status: "completed" });148 expect(shell.latestRun).toMatchObject({ runId, status: "completed" });
...@@ -149,10 +151,50 @@ describe("V2 client presentation", () => {...@@ -149,10 +151,50 @@ describe("V2 client presentation", () => {
149 activeRunId: null,151 activeRunId: null,
150 });152 });
151 expect(shell.pendingBackgroundTasks).toEqual([153 expect(shell.pendingBackgroundTasks).toEqual([
152 { taskId: "bg-1", description: "sleep 20", kind: "command" },154 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
153 ]);155 ]);
154 });156 });
155157
158 it.each([
159 { kinds: ["command"], expected: "completed" },
160 { kinds: ["command", "subagent"], expected: "idle" },
161 { kinds: ["background_task"], expected: "idle" },
162 ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => {
163 const pendingBackgroundTasks = kinds.map((kind, index) => ({
164 taskId: `bg-${index}`,
165 kind,
166 }));
167 const shell = presentThreadShell(environmentId, {
168 ...v2ThreadShell,
169 latestRunId: RunId.make("run-completed"),
170 activeRunId: null,
171 status: "completed",
172 pendingBackgroundTasks,
173 });
174
175 expect(shell.latestRun?.status).toBe("completed");
176 expect(shell.runtime).toMatchObject({ status: expected, activeRunId: null });
177 expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks);
178 });
179
180 it.each(["running", "waiting"] as const)(
181 "preserves shell %s while only commands remain in the roster",
182 (status) => {
183 const runId = RunId.make("run-command");
184 const shell = presentThreadShell(environmentId, {
185 ...v2ThreadShell,
186 latestRunId: runId,
187 activeRunId: runId,
188 status,
189 pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }],
190 });
191
192 expect(shell.latestRun?.status).toBe(status);
193 expect(shell.runtime).toMatchObject({ status, activeRunId: runId });
194 expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]);
195 },
196 );
197
156 it("keeps a failed latest run failed while background tasks are still pending", () => {198 it("keeps a failed latest run failed while background tasks are still pending", () => {
157 const shell = presentThreadShell(environmentId, {199 const shell = presentThreadShell(environmentId, {
158 ...v2ThreadShell,200 ...v2ThreadShell,
...@@ -160,7 +202,7 @@ describe("V2 client presentation", () => {...@@ -160,7 +202,7 @@ describe("V2 client presentation", () => {
160 activeRunId: null,202 activeRunId: null,
161 status: "failed",203 status: "failed",
162 lastError: "Provider turn failed",204 lastError: "Provider turn failed",
163 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],205 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
164 });206 });
165207
166 // Sidebar and mobile list read runtime "idle" as Waiting before failure.208 // Sidebar and mobile list read runtime "idle" as Waiting before failure.
...@@ -286,7 +328,7 @@ describe("V2 client presentation", () => {...@@ -286,7 +328,7 @@ describe("V2 client presentation", () => {
286 // Stale: server already projected a post-settlement roster, but shell328 // Stale: server already projected a post-settlement roster, but shell
287 // status still says running (packaged orchestrator-v2 bug).329 // status still says running (packaged orchestrator-v2 bug).
288 status: "running",330 status: "running",
289 pendingBackgroundTasks: [{ taskId: "bg-1", description: "sleep 20", kind: "command" }],331 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
290 });332 });
291333
292 expect(shell.latestRun).toMatchObject({ runId, status: "running" });334 expect(shell.latestRun).toMatchObject({ runId, status: "running" });
...@@ -295,7 +337,7 @@ describe("V2 client presentation", () => {...@@ -295,7 +337,7 @@ describe("V2 client presentation", () => {
295 activeRunId: runId,337 activeRunId: runId,
296 });338 });
297 expect(shell.pendingBackgroundTasks).toEqual([339 expect(shell.pendingBackgroundTasks).toEqual([
298 { taskId: "bg-1", description: "sleep 20", kind: "command" },340 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
299 ]);341 ]);
300 });342 });
301343
...@@ -307,7 +349,7 @@ describe("V2 client presentation", () => {...@@ -307,7 +349,7 @@ describe("V2 client presentation", () => {
307 activeRunId: runId,349 activeRunId: runId,
308 // Stale: checkpoint-oriented waiting masks post-settlement background work.350 // Stale: checkpoint-oriented waiting masks post-settlement background work.
309 status: "waiting",351 status: "waiting",
310 pendingBackgroundTasks: [{ taskId: "bg-2", description: "background bash", kind: "command" }],352 pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }],
311 });353 });
312354
313 expect(shell.latestRun).toMatchObject({ runId, status: "waiting" });355 expect(shell.latestRun).toMatchObject({ runId, status: "waiting" });
...@@ -316,7 +358,7 @@ describe("V2 client presentation", () => {...@@ -316,7 +358,7 @@ describe("V2 client presentation", () => {
316 activeRunId: runId,358 activeRunId: runId,
317 });359 });
318 expect(shell.pendingBackgroundTasks).toEqual([360 expect(shell.pendingBackgroundTasks).toEqual([
319 { taskId: "bg-2", description: "background bash", kind: "command" },361 { taskId: "bg-2", description: "Watch build", kind: "monitor" },
320 ]);362 ]);
321 });363 });
322364
...@@ -443,7 +485,7 @@ describe("V2 client presentation", () => {...@@ -443,7 +485,7 @@ describe("V2 client presentation", () => {
443 contextHandoffId: null,485 contextHandoffId: null,
444 };486 };
445 const backgroundItem = {487 const backgroundItem = {
446 id: TurnItemId.make("item-background-command"),488 id: TurnItemId.make("item-background-subagent"),
447 threadId: v2Projection.thread.id,489 threadId: v2Projection.thread.id,
448 runId,490 runId,
449 nodeId: null,491 nodeId: null,
...@@ -453,12 +495,18 @@ describe("V2 client presentation", () => {...@@ -453,12 +495,18 @@ describe("V2 client presentation", () => {
453 parentItemId: null,495 parentItemId: null,
454 ordinal: 0,496 ordinal: 0,
455 status: "running" as const,497 status: "running" as const,
456 title: "Background command",498 title: "Background review",
457 startedAt: now,499 startedAt: now,
458 completedAt: null,500 completedAt: null,
459 updatedAt: now,501 updatedAt: now,
460 type: "command_execution" as const,502 type: "subagent" as const,
461 input: "sleep 20",503 subagentId: NodeId.make("subagent-review"),
504 origin: "provider_native" as const,
505 driver: ProviderDriverKind.make("codex"),
506 providerInstanceId: v2Projection.thread.providerInstanceId,
507 childThreadId: null,
508 prompt: "Review the changes",
509 result: null,
462 };510 };
463511
464 expect(512 expect(
...@@ -488,6 +536,59 @@ describe("V2 client presentation", () => {...@@ -488,6 +536,59 @@ describe("V2 client presentation", () => {
488 turnItems: [backgroundItem],536 turnItems: [backgroundItem],
489 }),537 }),
490 ).toMatchObject({ status: "failed", activeRunId: null });538 ).toMatchObject({ status: "failed", activeRunId: null });
539 const commandItem = {
540 ...backgroundItem,
541 id: TurnItemId.make("item-background-command"),
542 type: "command_execution" as const,
543 input: "npm run dev",
544 };
545 expect(
546 deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }),
547 ).toMatchObject({ status: "waiting", activeRunId: null });
548
549 for (const [turnItems, status] of [
550 [[commandItem], "completed"],
551 [[backgroundItem], "idle"],
552 [[commandItem, backgroundItem], "idle"],
553 ] as const) {
554 expect(
555 deriveThreadRuntime({
556 ...v2Projection,
557 runs: [{ ...run, status: "completed", completedAt: now }],
558 turnItems,
559 }),
560 ).toMatchObject({ status, activeRunId: null });
561 }
562
563 for (const kind of ["monitor", "background_task"] as const) {
564 const providerThread = {
565 id: ProviderThreadId.make("provider-thread-background"),
566 driver: ProviderDriverKind.make("claudeCode"),
567 providerInstanceId: v2Projection.thread.providerInstanceId,
568 providerSessionId: null,
569 appThreadId: v2Projection.thread.id,
570 ownerNodeId: null,
571 nativeThreadRef: null,
572 nativeConversationHeadRef: null,
573 status: "idle" as const,
574 firstRunOrdinal: 1,
575 lastRunOrdinal: 1,
576 handoffIds: [],
577 forkedFrom: null,
578 pendingBackgroundTasks: [{ taskId: "background-task", kind }],
579 createdAt: now,
580 updatedAt: now,
581 };
582 for (const status of ["completed", "failed"] as const) {
583 expect(
584 deriveThreadRuntime({
585 ...v2Projection,
586 runs: [{ ...run, status, completedAt: now }],
587 providerThreads: [providerThread],
588 }),
589 ).toMatchObject({ status: status === "failed" ? "failed" : "idle", activeRunId: null });
590 }
591 }
491 });592 });
492593
493 it("joins pending request entities to their native turn-item display data", () => {594 it("joins pending request entities to their native turn-item display data", () => {
packages/client-runtime/src/state/models.ts+8-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
1import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";2import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
2import type {3import type {
3 ThreadLinkedPullRequest,4 ThreadLinkedPullRequest,
...@@ -159,15 +160,19 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {...@@ -159,15 +160,19 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean {
159 );160 );
160}161}
161162
162// Park runtime at idle when the post-settlement background roster is nonempty163// Park runtime at idle when the post-settlement background roster holds the
163// so #4415 waiting-presentation Waiting (session.idle) can consume CTM runtime.164// run's completion, so #4415 waiting-presentation Waiting (session.idle) can
165// consume CTM runtime. Only work that wakes the agent holds it: commands it
166// left running, such as a dev server, present the run's own status (#14872).
164// The server suppresses the roster while an interruptible activity run exists,167// The server suppresses the roster while an interruptible activity run exists,
165// so a remaining roster is stronger than checkpoint-oriented waiting.168// so a remaining roster is stronger than checkpoint-oriented waiting.
166// latestRun keeps the latest run's status for history presentation.169// latestRun keeps the latest run's status for history presentation.
167// A failed latest run outranks the roster, so the failure stays visible.170// A failed latest run outranks the roster, so the failure stays visible.
168function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {171function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null {
169 if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null;172 if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null;
170 const parkAtIdle = (thread.pendingBackgroundTasks?.length ?? 0) > 0 && thread.status !== "failed";173 const parkAtIdle =
174 backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) &&
175 thread.status !== "failed";
171 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);176 const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status);
172 return {177 return {
173 status,178 status,
packages/client-runtime/src/state/threadExecution.ts+6-3
...@@ -236,18 +236,21 @@ export function deriveThreadRuntime(...@@ -236,18 +236,21 @@ export function deriveThreadRuntime(
236 if (latestRun === null && projection.thread.activeProviderThreadId === null) return null;236 if (latestRun === null && projection.thread.activeProviderThreadId === null) return null;
237 const activeRunId =237 const activeRunId =
238 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;238 latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null;
239 const hasPendingBackgroundTasks =239 // Same rule as the shell runtime: only background work that holds the
240 // completion parks the thread at idle; a dev server left running does not.
241 const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion(
240 derivePendingBackgroundWork({242 derivePendingBackgroundWork({
241 latestRun: latestRunProjection,243 latestRun: latestRunProjection,
242 providerThreads: projection.providerThreads,244 providerThreads: projection.providerThreads,
243 turnItems: projection.turnItems,245 turnItems: projection.turnItems,
244 activeProviderThreadId: projection.thread.activeProviderThreadId,246 activeProviderThreadId: projection.thread.activeProviderThreadId,
245 runs: projection.runs,247 runs: projection.runs,
246 }).length > 0;248 }),
249 );
247 return {250 return {
248 status: usageLimitedRun251 status: usageLimitedRun
249 ? "failed"252 ? "failed"
250 : hasPendingBackgroundTasks && latestRunProjection?.status !== "failed"253 : backgroundWorkHoldsRun && latestRunProjection?.status !== "failed"
251 ? "idle"254 ? "idle"
252 : (activityRun?.status ?? "idle"),255 : (activityRun?.status ?? "idle"),
253 activeRunId,256 activeRunId,