1import type {
2 OrchestrationV2PendingBackgroundTask,
3 OrchestrationV2ProviderThread,
4 OrchestrationV2Run,
5 OrchestrationV2TurnItem,
6 ThreadId,
7 ThreadPullRequestLink,
8} from "@t3tools/contracts";
9import { isOrchestrationV2WorkActive } from "@t3tools/contracts";
10
11import { threadPullRequestKeyOf } from "./threadPullRequests.ts";
12
13const BACKGROUND_TURN_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([
14 "command_execution",
15 "dynamic_tool",
16 "subagent",
17]);
18
19/**
20 * True terminal run statuses (includes `rolled_back`). Retained as the
21 * canonical terminal set for this module; do not widen or reuse it as the
22 * background-wait gate — that gate intentionally excludes `rolled_back`.
23 */
24const TERMINAL_RUN_STATUSES = new Set<OrchestrationV2Run["status"]>([
25 "cancelled",
26 "completed",
27 "failed",
28 "interrupted",
29 "rolled_back",
30]);
31
32/**
33 * Run statuses that allow a pending-background roster to surface.
34 * Includes `waiting`: a successful turn persists as waiting until checkpoint
35 * capture flips it to completed, and waiting is only set from completed.
36 * Excludes `rolled_back`: the provider-thread roster is not run-tied, so a
37 * rolled-back latest run must fail the gate entirely (not only item filter).
38 * Built explicitly rather than spreading or deleting from TERMINAL_RUN_STATUSES.
39 */
40const SETTLED_FOR_BACKGROUND_WAIT_RUN_STATUSES = new Set<OrchestrationV2Run["status"]>([
41 "cancelled",
42 "completed",
43 "failed",
44 "interrupted",
45 "waiting",
46]);
47
48// Keep TERMINAL_RUN_STATUSES referenced so the true-terminal set stays defined
49// next to the background-wait subset (rolled_back is terminal, not wait-settled).
50void TERMINAL_RUN_STATUSES;
51
52export type PendingBackgroundWorkTask = OrchestrationV2PendingBackgroundTask;
53
54/**
55 * Whether a turn-item update can end background work that a settled run is
56 * still waiting on: an item of a background type that is no longer active.
57 * Streaming output on a running item, and every other item type, cannot.
58 */
59export function turnItemUpdateCanEndBackgroundWork(
60 item: Pick<OrchestrationV2TurnItem, "type" | "status">,
61): boolean {
62 return BACKGROUND_TURN_ITEM_TYPES.has(item.type) && !isOrchestrationV2WorkActive(item.status);
63}
64
65/**
66 * Whether background work left behind by a completed root run holds back its
67 * completion alert (desktop/web notification and the mobile push). Commands,
68 * such as dev servers and other long-lived shells, do not: the agent is done
69 * and may leave them running for hours. Subagents and monitors do, because
70 * they wake the agent and it continues (#13625). Work the adapter cannot name,
71 * including kinds this build does not know, holds as the conservative choice.
72 */
73export function backgroundWorkHoldsCompletion(
74 tasks: ReadonlyArray<Pick<PendingBackgroundWorkTask, "kind">>,
75): boolean {
76 return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind));
77}
78
79function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean {
80 switch (kind) {
81 case "command":
82 return false;
83 case "subagent":
84 case "monitor":
85 case "background_task":
86 return true;
87 }
88}
89
90type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">;
91
92type PendingBackgroundWorkPullRequest = Pick<
93 ThreadPullRequestLink,
94 "host" | "repository" | "number" | "url" | "source" | "watch"
95>;
96
97type PendingBackgroundWorkProviderThread = Pick<
98 OrchestrationV2ProviderThread,
99 "id" | "pendingBackgroundTasks"
100>;
101
102type PendingBackgroundWorkTurnItem = {
103 readonly id: OrchestrationV2TurnItem["id"] | string;
104 readonly type: OrchestrationV2TurnItem["type"];
105 readonly status: OrchestrationV2TurnItem["status"];
106 readonly title: string | null;
107 /** When present and the run is rolled_back, the item is abandoned, not pending. */
108 readonly runId?: OrchestrationV2Run["id"] | string | null;
109 readonly nativeItemRef?: {
110 readonly nativeId: string | null;
111 } | null;
112 readonly input?: unknown;
113 readonly prompt?: string | undefined;
114 readonly childThreadId?: ThreadId | null;
115};
116
117function isLatestRunSettledForBackgroundWait(
118 latestRun: PendingBackgroundWorkRun | null | undefined,
119): boolean {
120 if (latestRun === undefined || latestRun === null) {
121 return false;
122 }
123 return SETTLED_FOR_BACKGROUND_WAIT_RUN_STATUSES.has(latestRun.status);
124}
125
126function isPersistentDynamicToolInput(input: unknown): boolean {
127 if (input === null || typeof input !== "object" || Array.isArray(input)) {
128 return false;
129 }
130 return Reflect.get(input, "persistent") === true;
131}
132
133function descriptionFromTurnItem(item: PendingBackgroundWorkTurnItem): string | undefined {
134 if (typeof item.title === "string" && item.title.trim().length > 0) {
135 return item.title.trim();
136 }
137 if (item.type === "command_execution" && typeof item.input === "string") {
138 const command = item.input.trim();
139 return command.length > 0 ? command : undefined;
140 }
141 if (item.type === "dynamic_tool") {
142 const toolName = Reflect.get(item, "toolName");
143 if (typeof toolName === "string" && toolName.trim().length > 0) {
144 return toolName.trim();
145 }
146 }
147 if (item.type === "subagent" && typeof item.prompt === "string") {
148 const prompt = item.prompt.trim();
149 return prompt.length > 0 ? prompt : undefined;
150 }
151 return undefined;
152}
153
154function pendingTaskFromTurnItem(
155 taskId: string,
156 item: PendingBackgroundWorkTurnItem,
157): PendingBackgroundWorkTask {
158 const description = descriptionFromTurnItem(item);
159 const named = { taskId, ...(description === undefined ? {} : { description }) };
160 switch (item.type) {
161 case "subagent":
162 return {
163 ...named,
164 kind: "subagent",
165 ...(item.childThreadId == null ? {} : { childThreadId: item.childThreadId }),
166 };
167 case "command_execution":
168 return { ...named, kind: "command" };
169 default:
170 return { ...named, kind: "background_task" };
171 }
172}
173
174function nativeTaskIdFromTurnItem(item: PendingBackgroundWorkTurnItem): string {
175 const nativeId = item.nativeItemRef?.nativeId;
176 if (typeof nativeId === "string" && nativeId.length > 0) {
177 return nativeId;
178 }
179 return String(item.id);
180}
181
182/**
183 * The turn items the pending-work list names, without its settled-run gate.
184 * Stop ends exactly these, so the list and Stop cannot disagree.
185 */
186export function pendingBackgroundTurnItems<Item extends PendingBackgroundWorkTurnItem>(input: {
187 readonly turnItems: ReadonlyArray<Item>;
188 readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>;
189}): ReadonlyArray<Item> {
190 const rolledBackRunIds = new Set(
191 (input.runs ?? []).filter((run) => run.status === "rolled_back").map((run) => String(run.id)),
192 );
193 return input.turnItems.filter(
194 (item) =>
195 BACKGROUND_TURN_ITEM_TYPES.has(item.type) &&
196 isOrchestrationV2WorkActive(item.status) &&
197 !(item.type === "dynamic_tool" && isPersistentDynamicToolInput(item.input)) &&
198 // Null/absent run id stays eligible; only known rolled_back runs drop.
199 (item.runId === undefined ||
200 item.runId === null ||
201 !rolledBackRunIds.has(String(item.runId))),
202 );
203}
204
205/**
206 * Derive one normalized pending-background-work list for post-settlement UI.
207 *
208 * Sources:
209 * - Provider-thread roster (Claude SDK background tasks)
210 * - Active command_execution / dynamic_tool / subagent turn items
211 * - Pull request watches, as monitors: a watch wakes the agent, so the thread
212 * stays working between wakes instead of returning to the inbox. Callers
213 * that pick a run to interrupt leave `pullRequests` out; Stop ends watches
214 * on its own.
215 *
216 * Gated on latest root run settlement. Dedupes by native task ID. Excludes
217 * the roster while any interruptible foreground run remains active. Excludes
218 * Grok persistent monitors (`dynamic_tool` input with `persistent: true`).
219 * Excludes turn items whose run resolves to `rolled_back` (abandoned work);
220 * items with a null or absent run id stay eligible (matches SQL shell path).
221 * Does not consult subagent entities (those double-count turn items).
222 */
223export function derivePendingBackgroundWork(input: {
224 readonly latestRun: PendingBackgroundWorkRun | null | undefined;
225 readonly providerThreads: ReadonlyArray<PendingBackgroundWorkProviderThread>;
226 readonly turnItems: ReadonlyArray<PendingBackgroundWorkTurnItem>;
227 readonly activeProviderThreadId?: string | null;
228 readonly hasActiveRun?: boolean;
229 /**
230 * Run rows used to exclude items owned by rolled_back runs. Optional for
231 * callers that already filtered (SQL shell path); in-memory callers should
232 * pass projection runs so policy cannot drift.
233 */
234 readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>;
235 readonly pullRequests?: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined;
236}): ReadonlyArray<PendingBackgroundWorkTask> {
237 const hasActiveRun =
238 input.hasActiveRun ??
239 input.runs?.some(
240 (run) => run.status === "preparing" || run.status === "starting" || run.status === "running",
241 ) ??
242 false;
243 if (hasActiveRun) {
244 return [];
245 }
246 // A thread that never ran waits on nothing else, but a watch started on it still wakes it.
247 if (input.latestRun == null) {
248 return pullRequestWatchTasks(input.pullRequests);
249 }
250 if (!isLatestRunSettledForBackgroundWait(input.latestRun)) {
251 return [];
252 }
253
254 const byTaskId = new Map<string, PendingBackgroundWorkTask>();
255
256 const providerThreads =
257 input.activeProviderThreadId === undefined || input.activeProviderThreadId === null
258 ? input.providerThreads
259 : input.providerThreads.filter((thread) => thread.id === input.activeProviderThreadId);
260
261 for (const providerThread of providerThreads) {
262 for (const task of providerThread.pendingBackgroundTasks ?? []) {
263 if (task.taskId.length === 0 || byTaskId.has(task.taskId)) {
264 continue;
265 }
266 const { description: rawDescription, ...named } = task;
267 const description = rawDescription?.trim();
268 byTaskId.set(task.taskId, {
269 ...named,
270 ...(description === undefined || description.length === 0 ? {} : { description }),
271 });
272 }
273 }
274
275 for (const item of pendingBackgroundTurnItems(input)) {
276 const taskId = nativeTaskIdFromTurnItem(item);
277 if (byTaskId.has(taskId)) {
278 continue;
279 }
280
281 byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item));
282 }
283
284 for (const task of pullRequestWatchTasks(input.pullRequests)) byTaskId.set(task.taskId, task);
285
286 return Array.from(byTaskId.values());
287}
288
289function pullRequestWatchTasks(
290 pullRequests: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined,
291): Array<PendingBackgroundWorkTask> {
292 return (pullRequests ?? []).flatMap((link) =>
293 link.watch === undefined || link.source === "stack-dismissed"
294 ? []
295 : [
296 {
297 taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`,
298 description: `Watching pull request #${link.number}`,
299 kind: "monitor" as const,
300 },
301 ],
302 );
303}