| author | |
| committer | |
| log | f68e24fb419c985269cd0acb841e203af1419c43 |
| tree | 0b7e69fcdbbe758e1b8e7619fe9ca8e92f5866ef |
| parent | a5b34b25378fbbf90ce36ee101a11528ccf11c7e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { presentThreadShell } from "@t3tools/client-runtime/state/models"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 1 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; | 3 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; |
| 2 | import { | 4 | import { |
| 3 | createPendingThreadOrder, | 5 | createPendingThreadOrder, |
| ... | @@ -23,7 +25,7 @@ import { | ... | @@ -23,7 +25,7 @@ import { |
| 23 | import { describe, expect, it, vi } from "vite-plus/test"; | 25 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 24 | 26 | ||
| 25 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 27 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 26 | import { makeThreadShellFixture } from "../../test-fixtures"; | 28 | import { makeRawThreadShell, makeThreadShellFixture } from "../../test-fixtures"; |
| 27 | import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; | 29 | import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; |
| 28 | import { | 30 | import { |
| 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"; |
| 41 | 44 | ||
| ... | @@ -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 | }); |
| 178 | 179 | ||
| 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 agent | 62 | * 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 the | 64 | * 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 | */ |
| 67 | export type ThreadListV2Status = | 68 | export type ThreadListV2Status = |
| 68 | | "approval" | 69 | | "approval" |
apps/web/src/components/Sidebar.logic.test.ts+37-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import { presentThreadShell } from "@t3tools/client-runtime/state/models"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 1 | import { deriveActiveWorkStartedAt } from "../session-logic.ts"; | 3 | import { deriveActiveWorkStartedAt } from "../session-logic.ts"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; | 5 | import { 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 | }); |
| 2055 | 2057 | ||
| 2058 | describe("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 | |||
| 2056 | describe("Working shelf (beta)", () => { | 2091 | describe("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 @@ |
| 1 | import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; | 1 | import { resolveThreadWorkingStartedAt } from "@t3tools/client-runtime/state/models"; |
| 2 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 2 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; | 3 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 3 | import * as React from "react"; | 4 | import * as React from "react"; |
| 4 | import { | 5 | import { |
| ... | @@ -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 the | 911 | // (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. Waiting | 913 | // whether it finished, asked a question, or proposed a plan. Waiting |
| 913 | // (runtime status "idle") is the agent stopped with background tasks still | 914 | // (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 a | 915 | // 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 ready | 918 | // 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. |
| 918 | export type SidebarThreadStatus = | 920 | export type SidebarThreadStatus = |
| ... | @@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea | ... | @@ -1007,8 +1009,8 @@ export function shouldShowSidebarV2Duration(status: SidebarThreadStatus): boolea |
| 1007 | } | 1009 | } |
| 1008 | 1010 | ||
| 1009 | /** Working beta: threads busy with work that does not need the user fold into | 1011 | /** 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 tasks | 1012 | the Working shelf: a running run, or one stopped with background work |
| 1011 | still open. Approvals, questions, plan prompts, and failures stay in the | 1013 | that will wake it. Approvals, questions, plan prompts, and failures stay in the |
| 1012 | inbox. */ | 1014 | inbox. */ |
| 1013 | export function isSidebarThreadWorking(thread: ThreadStatusInput): boolean { | 1015 | export 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 | } |
| 1211 | 1213 | ||
| 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"; |
| 2 | import { useAtomValue } from "@effect/atom-react"; | 2 | import { useAtomValue } from "@effect/atom-react"; |
| 3 | import { useNavigate, useParams } from "@tanstack/react-router"; | 3 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 4 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 5 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 6 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 7 | import { | 6 | import { |
| 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 | ? completedAt | 133 | ? 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 | }); |
| 145 | 147 | ||
| 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 | }); |
| 155 | 157 | ||
| 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 | }); |
| 165 | 207 | ||
| 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 shell | 328 | // 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 | }); |
| 291 | 333 | ||
| 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 | }); |
| 301 | 343 | ||
| ... | @@ -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 | }); |
| 312 | 354 | ||
| 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 | }); |
| 322 | 364 | ||
| ... | @@ -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 | }; |
| 463 | 511 | ||
| 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 | }); |
| 492 | 593 | ||
| 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 @@ |
| 1 | import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | ||
| 1 | import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; | 2 | import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; |
| 2 | import type { | 3 | import 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 | } |
| 161 | 162 | ||
| 162 | // Park runtime at idle when the post-settlement background roster is nonempty | 163 | // 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. |
| 168 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { | 171 | function 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: usageLimitedRun | 251 | 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, |