pi-background-tasks 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/README.md +70 -25
  3. package/TESTING.md +42 -2
  4. package/TEST_PLAN.md +7 -4
  5. package/dist/extensions/anthropic-attribution-child.js +4 -0
  6. package/dist/extensions/anthropic-attribution-child.js.map +1 -0
  7. package/dist/extensions/anthropic-attribution.js +107 -0
  8. package/dist/extensions/anthropic-attribution.js.map +1 -0
  9. package/dist/extensions/background-tasks.js +2 -0
  10. package/dist/extensions/background-tasks.js.map +1 -0
  11. package/dist/extensions/delegate-child.js +2 -0
  12. package/dist/extensions/delegate-child.js.map +1 -0
  13. package/dist/extensions/fusion-child.js +2 -0
  14. package/dist/extensions/fusion-child.js.map +1 -0
  15. package/dist/package.json +5 -0
  16. package/dist/src/core/anthropic-attribution-path.js +24 -0
  17. package/dist/src/core/anthropic-attribution-path.js.map +1 -0
  18. package/dist/src/core/anthropic-attribution.js +2247 -0
  19. package/dist/src/core/anthropic-attribution.js.map +1 -0
  20. package/dist/src/core/attested-pi-contract.js +5 -0
  21. package/dist/src/core/attested-pi-contract.js.map +1 -0
  22. package/dist/src/core/attested-pi-run.js +749 -0
  23. package/dist/src/core/attested-pi-run.js.map +1 -0
  24. package/dist/src/core/canonical-json.js +19 -0
  25. package/dist/src/core/canonical-json.js.map +1 -0
  26. package/dist/src/core/common.js +787 -0
  27. package/dist/src/core/common.js.map +1 -0
  28. package/dist/src/core/config.js +78 -0
  29. package/dist/src/core/config.js.map +1 -0
  30. package/dist/src/core/context/parent-snapshot.js +75 -0
  31. package/dist/src/core/context/parent-snapshot.js.map +1 -0
  32. package/dist/src/core/context/token-budget.js +631 -0
  33. package/dist/src/core/context/token-budget.js.map +1 -0
  34. package/dist/src/core/context/visible-conversation-v2.js +390 -0
  35. package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
  36. package/dist/src/core/delegate/artifacts.js +357 -0
  37. package/dist/src/core/delegate/artifacts.js.map +1 -0
  38. package/dist/src/core/delegate/budget.js +266 -0
  39. package/dist/src/core/delegate/budget.js.map +1 -0
  40. package/dist/src/core/delegate/facade-contract.js +7 -0
  41. package/dist/src/core/delegate/facade-contract.js.map +1 -0
  42. package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
  43. package/dist/src/core/delegate/hook-contract.js +124 -0
  44. package/dist/src/core/delegate/hook-contract.js.map +1 -0
  45. package/dist/src/core/delegate/launch.js +366 -0
  46. package/dist/src/core/delegate/launch.js.map +1 -0
  47. package/dist/src/core/delegate/result-package.js +293 -0
  48. package/dist/src/core/delegate/result-package.js.map +1 -0
  49. package/dist/src/core/delegate/runner.js +398 -0
  50. package/dist/src/core/delegate/runner.js.map +1 -0
  51. package/dist/src/core/delegate/seed.js +334 -0
  52. package/dist/src/core/delegate/seed.js.map +1 -0
  53. package/dist/src/core/delegate/types.js +103 -0
  54. package/dist/src/core/delegate/types.js.map +1 -0
  55. package/dist/src/core/durable-fs.js +372 -0
  56. package/dist/src/core/durable-fs.js.map +1 -0
  57. package/dist/src/core/extension-api.js +382 -0
  58. package/dist/src/core/extension-api.js.map +1 -0
  59. package/dist/src/core/fusion/artifacts.js +654 -0
  60. package/dist/src/core/fusion/artifacts.js.map +1 -0
  61. package/dist/src/core/fusion/budget.js +949 -0
  62. package/dist/src/core/fusion/budget.js.map +1 -0
  63. package/dist/src/core/fusion/child-protocol.js +176 -0
  64. package/dist/src/core/fusion/child-protocol.js.map +1 -0
  65. package/dist/src/core/fusion/claude-cache.js +156 -0
  66. package/dist/src/core/fusion/claude-cache.js.map +1 -0
  67. package/dist/src/core/fusion/clean-context.js +64 -0
  68. package/dist/src/core/fusion/clean-context.js.map +1 -0
  69. package/dist/src/core/fusion/config.js +349 -0
  70. package/dist/src/core/fusion/config.js.map +1 -0
  71. package/dist/src/core/fusion/context.js +184 -0
  72. package/dist/src/core/fusion/context.js.map +1 -0
  73. package/dist/src/core/fusion/evaluation.js +696 -0
  74. package/dist/src/core/fusion/evaluation.js.map +1 -0
  75. package/dist/src/core/fusion/facade-contract.js +3 -0
  76. package/dist/src/core/fusion/facade-contract.js.map +1 -0
  77. package/dist/src/core/fusion/orchestrator.js +867 -0
  78. package/dist/src/core/fusion/orchestrator.js.map +1 -0
  79. package/dist/src/core/fusion/output-contract.js +27 -0
  80. package/dist/src/core/fusion/output-contract.js.map +1 -0
  81. package/dist/src/core/fusion/pi-child.js +1741 -0
  82. package/dist/src/core/fusion/pi-child.js.map +1 -0
  83. package/dist/src/core/fusion/prompts.js +260 -0
  84. package/dist/src/core/fusion/prompts.js.map +1 -0
  85. package/dist/src/core/fusion/result-package.js +857 -0
  86. package/dist/src/core/fusion/result-package.js.map +1 -0
  87. package/dist/src/core/fusion/source-policy.js +259 -0
  88. package/dist/src/core/fusion/source-policy.js.map +1 -0
  89. package/dist/src/core/fusion/types.js +207 -0
  90. package/dist/src/core/fusion/types.js.map +1 -0
  91. package/dist/src/core/fusion/web-fetch.js +777 -0
  92. package/dist/src/core/fusion/web-fetch.js.map +1 -0
  93. package/dist/src/core/fusion/workflows.js +126 -0
  94. package/dist/src/core/fusion/workflows.js.map +1 -0
  95. package/dist/src/core/lazy-module.js +181 -0
  96. package/dist/src/core/lazy-module.js.map +1 -0
  97. package/dist/src/core/pi-launch.js +431 -0
  98. package/dist/src/core/pi-launch.js.map +1 -0
  99. package/dist/src/core/registry.js +3331 -0
  100. package/dist/src/core/registry.js.map +1 -0
  101. package/dist/src/core/reload-shell-owner.js +1396 -0
  102. package/dist/src/core/reload-shell-owner.js.map +1 -0
  103. package/dist/src/core/shell-policy.js +80 -0
  104. package/dist/src/core/shell-policy.js.map +1 -0
  105. package/dist/src/core/task-durable.js +51 -0
  106. package/dist/src/core/task-durable.js.map +1 -0
  107. package/dist/src/core/update-check.js +92 -0
  108. package/dist/src/core/update-check.js.map +1 -0
  109. package/dist/src/core/windows-taskkill.js +185 -0
  110. package/dist/src/core/windows-taskkill.js.map +1 -0
  111. package/dist/src/delegate-child-extension.js +776 -0
  112. package/dist/src/delegate-child-extension.js.map +1 -0
  113. package/dist/src/delegate-extension.js +746 -0
  114. package/dist/src/delegate-extension.js.map +1 -0
  115. package/dist/src/extension.js +953 -0
  116. package/dist/src/extension.js.map +1 -0
  117. package/dist/src/fusion-child-extension.js +760 -0
  118. package/dist/src/fusion-child-extension.js.map +1 -0
  119. package/dist/src/fusion-extension.js +1030 -0
  120. package/dist/src/fusion-extension.js.map +1 -0
  121. package/dist/src/ui/background-tasks-manager.js +689 -0
  122. package/dist/src/ui/background-tasks-manager.js.map +1 -0
  123. package/dist/src/ui/fusion-model-selector.js +277 -0
  124. package/dist/src/ui/fusion-model-selector.js.map +1 -0
  125. package/docs/INDEX.md +35 -33
  126. package/docs/api/eventbus-v1.md +21 -5
  127. package/docs/choose-a-workflow.md +2 -0
  128. package/docs/commands/bg-clear.md +3 -3
  129. package/docs/commands/bg-update.md +3 -3
  130. package/docs/commands/bg.md +18 -6
  131. package/docs/commands/claude-cache.md +4 -4
  132. package/docs/commands/fusion-models.md +49 -4
  133. package/docs/commands/fusion.md +3 -3
  134. package/docs/commands/jobs.md +3 -3
  135. package/docs/commands/kill.md +3 -3
  136. package/docs/commands/logs.md +3 -3
  137. package/docs/commands/task-manager.md +5 -5
  138. package/docs/concepts/completion-delivery.md +6 -2
  139. package/docs/getting-started.md +7 -1
  140. package/docs/manifest.json +228 -42
  141. package/docs/operations/configuration.md +58 -5
  142. package/docs/operations/releasing.md +4 -2
  143. package/docs/operations/testing.md +22 -0
  144. package/docs/operations/troubleshooting.md +6 -2
  145. package/docs/read-before-edit.md +44 -31
  146. package/docs/reference/runtime-contracts.md +87 -51
  147. package/docs/reference/shortcuts-and-dock.md +28 -14
  148. package/docs/subsystems/anthropic-attribution.md +23 -11
  149. package/docs/subsystems/attested-pi-runs.md +12 -7
  150. package/docs/subsystems/background-task-runtime.md +53 -11
  151. package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
  152. package/docs/subsystems/delegation.md +16 -3
  153. package/docs/subsystems/docs-freshness-gate.md +14 -7
  154. package/docs/subsystems/fusion.md +36 -11
  155. package/docs/subsystems/host-ui-and-telemetry.md +22 -9
  156. package/docs/tools/bg_delegate.md +3 -1
  157. package/docs/tools/bg_kill.md +3 -1
  158. package/docs/tools/bg_logs.md +3 -1
  159. package/docs/tools/bg_result.md +5 -3
  160. package/docs/tools/bg_run.md +31 -5
  161. package/docs/tools/bg_run_pi_attested.md +3 -1
  162. package/docs/tools/bg_status.md +3 -1
  163. package/docs/tools/fusion_investigate.md +3 -1
  164. package/docs/tools/fusion_reason.md +3 -1
  165. package/docs/tools/fusion_research.md +3 -1
  166. package/docs/tools/fusion_validate.md +3 -1
  167. package/extensions/anthropic-attribution-child.ts +3 -0
  168. package/extensions/anthropic-attribution.ts +166 -1
  169. package/package.json +12 -10
  170. package/src/core/anthropic-attribution-path.ts +5 -4
  171. package/src/core/anthropic-attribution.ts +293 -113
  172. package/src/core/attested-pi-contract.ts +4 -0
  173. package/src/core/attested-pi-run.ts +468 -81
  174. package/src/core/canonical-json.ts +21 -0
  175. package/src/core/common.ts +450 -40
  176. package/src/core/config.ts +121 -0
  177. package/src/core/context/visible-conversation-v2.ts +3 -6
  178. package/src/core/delegate/artifacts.ts +13 -9
  179. package/src/core/delegate/budget.ts +13 -6
  180. package/src/core/delegate/facade-contract.ts +6 -0
  181. package/src/core/delegate/result-package.ts +32 -14
  182. package/src/core/delegate/runner.ts +129 -58
  183. package/src/core/delegate/seed.ts +5 -7
  184. package/src/core/durable-fs.ts +178 -30
  185. package/src/core/extension-api.ts +36 -8
  186. package/src/core/fusion/artifacts.ts +17 -16
  187. package/src/core/fusion/clean-context.ts +8 -5
  188. package/src/core/fusion/config.ts +2 -1
  189. package/src/core/fusion/context.ts +2 -6
  190. package/src/core/fusion/facade-contract.ts +2 -0
  191. package/src/core/fusion/orchestrator.ts +3 -3
  192. package/src/core/fusion/prompts.ts +1 -1
  193. package/src/core/fusion/result-package.ts +259 -72
  194. package/src/core/fusion/source-policy.ts +43 -17
  195. package/src/core/lazy-module.ts +215 -0
  196. package/src/core/pi-launch.ts +390 -65
  197. package/src/core/registry.ts +2010 -381
  198. package/src/core/reload-shell-owner.ts +1662 -0
  199. package/src/core/shell-policy.ts +134 -0
  200. package/src/core/task-durable.ts +67 -0
  201. package/src/delegate-extension.ts +404 -144
  202. package/src/extension.ts +387 -114
  203. package/src/fusion-extension.ts +202 -96
@@ -0,0 +1,3331 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { createWriteStream, existsSync } from 'node:fs';
4
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { formatSize } from '@earendil-works/pi-coding-agent';
7
+ import { boundedRead, deriveTaskNameFromCommand, escapeXml, formatAgentActivityLine, formatDuration, isJsonObject, normalizeTaskName, parseAgentActivity, parseJsonText, rejectSurvivalForTaskKind, resolveShellPolicy, sanitizePathSegment, shellInvocationForPolicy, shellPolicySnapshot, shellQuote, snapshot, taskDisplayName, ReloadSurvivalError, } from './common.js';
8
+ import { ATTESTED_GIT_KILL_GRACE_MS, ATTESTED_GIT_MAX_OUTPUT_BYTES, ATTESTED_TASK_ID_PATTERN, } from './attested-pi-contract.js';
9
+ import { closeAndFsyncOutputStream, writeFileFsynced, writeJsonAtomic } from './task-durable.js';
10
+ import { assertWindowsCommandLineWithinLimit, piLaunchArgv, resolvePiLaunch, } from './pi-launch.js';
11
+ import { BackgroundTaskExtensionServiceClosedError } from './extension-api.js';
12
+ import { resolveAnthropicAttributionExtensionPath } from './anthropic-attribution-path.js';
13
+ import { createReloadableShellExecutionV1, RELOAD_SHELL_OWNER_PROTOCOL, } from './reload-shell-owner.js';
14
+ import { runWindowsTaskkill, } from './windows-taskkill.js';
15
+ export const MAX_OUTPUT_BYTES = Number(process.env['PI_BG_MAX_OUTPUT_BYTES'] ?? 20 * 1024 * 1024);
16
+ export const KILL_GRACE_MS = 3000;
17
+ export const STOP_WAIT_MS = KILL_GRACE_MS + 1500;
18
+ export const MAX_RECENT_TASKS = 100;
19
+ export const TERMINAL_PUBLICATION_MAX_ATTEMPTS = 3;
20
+ export const TERMINAL_PUBLICATION_RETRY_MS = 100;
21
+ export const TASK_ADMISSION_TIMEOUT_MS = 30_000;
22
+ const TERMINAL_PUBLICATION_DIAGNOSTIC_CHARS = 500;
23
+ const TELEMETRY_BUFFER_CHARS = 512 * 1024;
24
+ export class BackgroundTaskAdmissionClosedError extends Error {
25
+ code = 'pi_background_tasks_admission_closed';
26
+ constructor(kind) {
27
+ super(`Cannot start ${kind} after background task admissions have closed`);
28
+ this.name = 'BackgroundTaskAdmissionClosedError';
29
+ }
30
+ }
31
+ export class BackgroundTaskAdmissionTimeoutError extends Error {
32
+ code = 'pi_background_tasks_admission_timeout';
33
+ constructor(kind, timeoutMs) {
34
+ super(`Timed out while preparing ${kind} after ${String(timeoutMs)}ms`);
35
+ this.name = 'BackgroundTaskAdmissionTimeoutError';
36
+ }
37
+ }
38
+ export const WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON = 'win32-cmd-cannot-safely-intercept-pi-argv';
39
+ export const NON_POSIX_SHELL_PI_TELEMETRY_UNAVAILABLE_REASON = 'user-non-posix-shell-cannot-safely-intercept-pi-argv';
40
+ function defaultTaskId() {
41
+ return `b${randomBytes(4).toString('hex')}`;
42
+ }
43
+ function dirNameFromDisplay(path) {
44
+ const parts = path.split(/[\\/]/);
45
+ return parts.length >= 2 ? (parts.at(-2) ?? '') : '';
46
+ }
47
+ export function commandMayLaunchPiAgent(command, env = process.env) {
48
+ if (env['PI_BG_DISABLE_PI_TELEMETRY'] === '1')
49
+ return false;
50
+ return /(^|[\s;&|()])pi(?=\s)(?=[^\n;&|]*(?:\s-p(?:\s|$)|\s--print(?:\s|$)|\s--mode(?:=|\s+)json\b))/m.test(command);
51
+ }
52
+ export function buildModelWindowIndex(ctx) {
53
+ const byQualifiedId = {};
54
+ const candidatesById = new Map();
55
+ for (const model of ctx.modelRegistry.getAll()) {
56
+ const contextWindow = typeof model.contextWindow === 'number' &&
57
+ Number.isFinite(model.contextWindow) &&
58
+ model.contextWindow > 0
59
+ ? Math.floor(model.contextWindow)
60
+ : undefined;
61
+ if (!contextWindow)
62
+ continue;
63
+ byQualifiedId[`${model.provider}/${model.id}`] = contextWindow;
64
+ let candidates = candidatesById.get(model.id);
65
+ if (!candidates) {
66
+ candidates = new Set();
67
+ candidatesById.set(model.id, candidates);
68
+ }
69
+ candidates.add(contextWindow);
70
+ }
71
+ const byId = {};
72
+ for (const [id, windows] of candidatesById) {
73
+ const onlyWindow = windows.values().next();
74
+ if (windows.size === 1 && !onlyWindow.done)
75
+ byId[id] = onlyWindow.value;
76
+ }
77
+ const current = ctx.model;
78
+ return {
79
+ byQualifiedId,
80
+ byId,
81
+ defaultModel: current?.id,
82
+ defaultProvider: current?.provider,
83
+ defaultContextWindow: current?.contextWindow,
84
+ };
85
+ }
86
+ export function createPiTelemetryWrapperSource(index, launch = resolvePiLaunch()) {
87
+ return `#!/usr/bin/env node
88
+ const { spawn } = require("node:child_process");
89
+ const index = ${JSON.stringify(index)};
90
+ const launch = ${JSON.stringify(launch)};
91
+ const WINDOWS_COMMAND_LINE_LIMIT = 32767;
92
+
93
+ const tokenUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
94
+ let costTotal = 0;
95
+ let hasCostTotal = false;
96
+ let agentModel;
97
+ const toolUsage = { total: 0, failed: 0, byName: {} };
98
+ const seenToolCallIds = new Set();
99
+ const failedToolCallIds = new Set();
100
+
101
+ function nonNegativeInteger(value) {
102
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
103
+ }
104
+
105
+ function normalizeUsage(usage) {
106
+ if (!usage) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0 };
107
+ const input = nonNegativeInteger(usage.input);
108
+ const output = nonNegativeInteger(usage.output);
109
+ const cacheRead = nonNegativeInteger(usage.cacheRead);
110
+ const cacheWrite = nonNegativeInteger(usage.cacheWrite);
111
+ const explicitTotal = nonNegativeInteger(usage.totalTokens);
112
+ const totalTokens = explicitTotal || (input + output + cacheRead + cacheWrite);
113
+ const cost = usage.cost && typeof usage.cost.total === "number" && Number.isFinite(usage.cost.total) && usage.cost.total >= 0
114
+ ? usage.cost.total
115
+ : undefined;
116
+ return { input, output, cacheRead, cacheWrite, totalTokens, cost };
117
+ }
118
+
119
+ function addTokenUsage(usage) {
120
+ const normalized = normalizeUsage(usage);
121
+ if (!normalized.totalTokens) return normalized;
122
+ tokenUsage.input += normalized.input;
123
+ tokenUsage.output += normalized.output;
124
+ tokenUsage.cacheRead += normalized.cacheRead;
125
+ tokenUsage.cacheWrite += normalized.cacheWrite;
126
+ tokenUsage.totalTokens += normalized.totalTokens;
127
+ if (normalized.cost !== undefined) {
128
+ costTotal += normalized.cost;
129
+ hasCostTotal = true;
130
+ }
131
+ return normalized;
132
+ }
133
+
134
+ function currentTokenUsage() {
135
+ if (!tokenUsage.totalTokens) return undefined;
136
+ const out = { ...tokenUsage };
137
+ if (hasCostTotal) out.costTotal = costTotal;
138
+ return out;
139
+ }
140
+
141
+ function markToolStarted(id, name) {
142
+ const key = id ? String(id) : undefined;
143
+ if (key && seenToolCallIds.has(key)) return;
144
+ if (key) seenToolCallIds.add(key);
145
+ const toolName = name ? String(name) : "unknown";
146
+ toolUsage.total += 1;
147
+ toolUsage.byName[toolName] = (toolUsage.byName[toolName] || 0) + 1;
148
+ }
149
+
150
+ function markToolFailed(id) {
151
+ const key = id ? String(id) : undefined;
152
+ if (key && failedToolCallIds.has(key)) return;
153
+ if (key) failedToolCallIds.add(key);
154
+ toolUsage.failed += 1;
155
+ }
156
+
157
+ function currentToolUsage() {
158
+ if (!toolUsage.total && !toolUsage.failed) return undefined;
159
+ return { total: toolUsage.total, failed: toolUsage.failed, byName: { ...toolUsage.byName } };
160
+ }
161
+
162
+ function renderWindowsArgument(value) {
163
+ if (value.length > 0 && !/[ \\t\"]/.test(value)) return value;
164
+ let rendered = "\\\"";
165
+ let backslashes = 0;
166
+ for (const char of value) {
167
+ if (char === "\\\\") {
168
+ backslashes += 1;
169
+ continue;
170
+ }
171
+ if (char === "\\\"") {
172
+ rendered += "\\\\".repeat(backslashes * 2 + 1);
173
+ rendered += "\\\"";
174
+ backslashes = 0;
175
+ continue;
176
+ }
177
+ if (backslashes > 0) {
178
+ rendered += "\\\\".repeat(backslashes);
179
+ backslashes = 0;
180
+ }
181
+ rendered += char;
182
+ }
183
+ if (backslashes > 0) rendered += "\\\\".repeat(backslashes * 2);
184
+ rendered += "\\\"";
185
+ return rendered;
186
+ }
187
+
188
+ function assertWindowsLimit(stage, args) {
189
+ if (process.platform !== "win32") return;
190
+ const measured = [launch.executable, ...launch.argvPrefix, ...args].map(renderWindowsArgument).join(" ").length + 1;
191
+ if (measured > WINDOWS_COMMAND_LINE_LIMIT) {
192
+ const error = new Error("pi_command_line_too_long: " + stage + " measured UTF-16 command line length " + String(measured) + " exceeds limit " + String(WINDOWS_COMMAND_LINE_LIMIT));
193
+ error.code = "pi_command_line_too_long";
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ function emitUnifiedTelemetry(payload) {
199
+ const out = { type: "background-task-telemetry", ...payload };
200
+ const tokens = currentTokenUsage();
201
+ const tools = currentToolUsage();
202
+ if (tokens && !out.tokenUsage) out.tokenUsage = tokens;
203
+ if (tools && !out.toolUsage) out.toolUsage = tools;
204
+ if (agentModel && !out.model) out.model = agentModel;
205
+ process.stdout.write(JSON.stringify(out) + "\\n");
206
+ }
207
+
208
+ function emitActivity(activity) {
209
+ process.stdout.write(JSON.stringify({ type: "background-task-activity", ...activity }) + "\\n");
210
+ }
211
+
212
+ function summarizeArgs(args) {
213
+ if (!args || typeof args !== "object") return "";
214
+ const pick = (value) => {
215
+ if (typeof value === "string" && value.trim()) return value.trim().slice(0, 200);
216
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
217
+ return undefined;
218
+ };
219
+ const preferred = ["path", "file_path", "file", "filename", "command", "cmd", "pattern", "query", "url", "name", "value", "text", "message"];
220
+ for (const key of preferred) { const summary = pick(args[key]); if (summary) return summary; }
221
+ for (const key of Object.keys(args)) { const summary = pick(args[key]); if (summary) return summary; }
222
+ return "";
223
+ }
224
+
225
+ function emitAssistantActivity(message) {
226
+ const content = message && Array.isArray(message.content) ? message.content : [];
227
+ for (const part of content) {
228
+ if (!part || typeof part !== "object") continue;
229
+ if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
230
+ emitActivity({ kind: "assistant_text", text: part.text });
231
+ } else if (part.type === "thinking" || part.type === "reasoning") {
232
+ const text = typeof part.text === "string" ? part.text : (typeof part.thinking === "string" ? part.thinking : "");
233
+ if (text.trim()) emitActivity({ kind: "reasoning", text: text });
234
+ }
235
+ }
236
+ }
237
+
238
+ function resolveModelName(fromMessage, fromArgs, providerFromArgs) {
239
+ const message = fromMessage ? String(fromMessage) : "";
240
+ const args = fromArgs ? String(fromArgs) : "";
241
+ const bareOf = (value) => value.includes("/") ? value.split("/").pop() : value;
242
+ if (message && message.includes("/")) return message;
243
+ if (args && args.includes("/") && (!message || bareOf(args) === message)) return args;
244
+ const primary = message || args;
245
+ if (!primary) return undefined;
246
+ if (primary.includes("/")) return primary;
247
+ if (providerFromArgs) return providerFromArgs + "/" + primary;
248
+ if (index.defaultProvider) return index.defaultProvider + "/" + primary;
249
+ return primary;
250
+ }
251
+
252
+ function parseInvocation(argv) {
253
+ const out = [];
254
+ let model;
255
+ let provider;
256
+ let hasMode = false;
257
+ let modeValue;
258
+ for (let i = 0; i < argv.length; i++) {
259
+ const arg = argv[i];
260
+ if (arg === "-p" || arg === "--print") continue;
261
+ if (arg === "--mode") {
262
+ hasMode = true;
263
+ modeValue = argv[i + 1];
264
+ out.push(arg);
265
+ if (i + 1 < argv.length) out.push(argv[++i]);
266
+ continue;
267
+ }
268
+ if (arg.startsWith("--mode=")) {
269
+ hasMode = true;
270
+ modeValue = arg.slice("--mode=".length);
271
+ out.push(arg);
272
+ continue;
273
+ }
274
+ if (arg === "--model" && i + 1 < argv.length) {
275
+ model = argv[i + 1];
276
+ out.push(arg, argv[++i]);
277
+ continue;
278
+ }
279
+ if (arg.startsWith("--model=")) model = arg.slice("--model=".length);
280
+ if (arg === "--provider" && i + 1 < argv.length) {
281
+ provider = argv[i + 1];
282
+ out.push(arg, argv[++i]);
283
+ continue;
284
+ }
285
+ if (arg.startsWith("--provider=")) provider = arg.slice("--provider=".length);
286
+ out.push(arg);
287
+ }
288
+ if (hasMode && modeValue !== "json") return { args: argv, parseJson: false, model, provider };
289
+ if (!hasMode) out.unshift("--mode", "json");
290
+ return { args: out, parseJson: true, model, provider };
291
+ }
292
+
293
+ function resolveWindow(modelFromArgs, providerFromArgs, modelFromMessage) {
294
+ const candidates = [];
295
+ if (modelFromMessage) candidates.push(modelFromMessage);
296
+ if (modelFromArgs) candidates.push(modelFromArgs);
297
+ if (modelFromArgs && providerFromArgs && !modelFromArgs.includes("/")) candidates.push(providerFromArgs + "/" + modelFromArgs);
298
+ if (modelFromArgs && index.defaultProvider && !modelFromArgs.includes("/")) candidates.push(index.defaultProvider + "/" + modelFromArgs);
299
+ if (index.defaultModel && index.defaultProvider) candidates.push(index.defaultProvider + "/" + index.defaultModel);
300
+ for (const candidate of candidates) {
301
+ if (!candidate) continue;
302
+ if (index.byQualifiedId[candidate]) return index.byQualifiedId[candidate];
303
+ const bare = String(candidate).includes("/") ? String(candidate).split("/").pop() : String(candidate);
304
+ if (bare && index.byId[bare]) return index.byId[bare];
305
+ }
306
+ return index.defaultContextWindow || 0;
307
+ }
308
+
309
+ function countToolCallsFromMessage(message) {
310
+ const content = message && Array.isArray(message.content) ? message.content : [];
311
+ for (const part of content) {
312
+ if (part && part.type === "toolCall") markToolStarted(part.id, part.name);
313
+ }
314
+ }
315
+
316
+ function emitMessageTelemetry(message, modelFromArgs, providerFromArgs) {
317
+ const usage = addTokenUsage(message && message.usage);
318
+ const resolvedModel = resolveModelName(message && message.model, modelFromArgs, providerFromArgs);
319
+ if (resolvedModel) agentModel = resolvedModel;
320
+ const contextWindow = resolveWindow(modelFromArgs, providerFromArgs, message && message.model);
321
+ const contextUsage = usage.totalTokens && contextWindow
322
+ ? { tokens: usage.totalTokens, contextWindow, percent: (usage.totalTokens / contextWindow) * 100 }
323
+ : undefined;
324
+ if (contextUsage) process.stdout.write(JSON.stringify({ type: "background-task-context-usage", ...contextUsage }) + "\\n");
325
+ const payload = {};
326
+ if (contextUsage) payload.contextUsage = contextUsage;
327
+ emitUnifiedTelemetry(payload);
328
+ }
329
+
330
+ function emitToolTelemetry() {
331
+ emitUnifiedTelemetry({});
332
+ }
333
+
334
+ const parsed = parseInvocation(process.argv.slice(2));
335
+ let child;
336
+ let buffer = "";
337
+ try {
338
+ const childArgs = [...launch.argvPrefix, ...parsed.args];
339
+ assertWindowsLimit("telemetry-wrapper-pi", parsed.args);
340
+ child = spawn(launch.executable, childArgs, { stdio: ["ignore", "pipe", "pipe"], env: process.env, shell: false, windowsHide: true });
341
+ } catch (error) {
342
+ const message = error && typeof error.message === "string" ? error.message : String(error);
343
+ process.stderr.write("[pi-bg telemetry wrapper error: " + message + "]\\n");
344
+ process.exitCode = 1;
345
+ }
346
+
347
+ if (child) {
348
+ if (!parsed.parseJson) {
349
+ child.stdout.pipe(process.stdout);
350
+ } else {
351
+ child.stdout.on("data", (chunk) => {
352
+ buffer += chunk.toString();
353
+ const lines = buffer.split("\\n");
354
+ buffer = lines.pop() || "";
355
+ for (const line of lines) processLine(line);
356
+ });
357
+ }
358
+ child.stderr.on("data", (chunk) => process.stderr.write(chunk));
359
+ child.on("error", (error) => {
360
+ process.stderr.write("[pi-bg telemetry wrapper error: " + error.message + "]\\n");
361
+ });
362
+ child.on("close", (code, signal) => {
363
+ if (parsed.parseJson && buffer.trim()) processLine(buffer);
364
+ // Never call process.exit() here: the final message telemetry may still be
365
+ // buffered on wrapper stdout, and forced exit can publish a stale context
366
+ // snapshot from the preceding assistant turn. exitCode lets Node drain the
367
+ // pipe; signal termination is deferred through the same stdout barrier.
368
+ process.stdout.write("", () => {
369
+ if (signal) process.kill(process.pid, signal);
370
+ else process.exitCode = code ?? 0;
371
+ });
372
+ });
373
+ }
374
+
375
+ function processLine(line) {
376
+ if (!line.trim()) return;
377
+ let event;
378
+ try {
379
+ event = JSON.parse(line);
380
+ } catch {
381
+ process.stdout.write(line + "\\n");
382
+ return;
383
+ }
384
+ if (event.type === "tool_execution_start") {
385
+ const toolName = event.toolName || event.tool_name || "tool";
386
+ markToolStarted(event.toolCallId || event.tool_call_id, toolName);
387
+ emitActivity({ kind: "tool_start", tool: String(toolName), argsSummary: summarizeArgs(event.args || event.arguments || event.input || event.parameters) });
388
+ emitToolTelemetry();
389
+ return;
390
+ }
391
+ if (event.type === "tool_execution_end") {
392
+ const toolName = event.toolName || event.tool_name || "tool";
393
+ if (event.isError) markToolFailed(event.toolCallId || event.tool_call_id);
394
+ emitActivity({ kind: "tool_end", tool: String(toolName), isError: !!event.isError, error: typeof event.error === "string" ? event.error : undefined });
395
+ emitToolTelemetry();
396
+ return;
397
+ }
398
+ if (event.type === "message_end" && event.message && event.message.role === "assistant") {
399
+ emitAssistantActivity(event.message);
400
+ countToolCallsFromMessage(event.message);
401
+ emitMessageTelemetry(event.message, parsed.model, parsed.provider);
402
+ }
403
+ }
404
+ `;
405
+ }
406
+ function normalizeContextUsage(value) {
407
+ if (!isJsonObject(value))
408
+ return undefined;
409
+ const input = value;
410
+ const rawContextWindow = input.contextWindow;
411
+ const contextWindow = typeof rawContextWindow === 'number' &&
412
+ Number.isFinite(rawContextWindow) &&
413
+ rawContextWindow > 0
414
+ ? Math.floor(rawContextWindow)
415
+ : undefined;
416
+ if (!contextWindow)
417
+ return undefined;
418
+ const rawTokens = input.tokens;
419
+ const tokens = rawTokens === null
420
+ ? null
421
+ : typeof rawTokens === 'number' && Number.isFinite(rawTokens) && rawTokens >= 0
422
+ ? Math.floor(rawTokens)
423
+ : null;
424
+ const rawPercent = input.percent;
425
+ const percent = rawPercent === null
426
+ ? null
427
+ : typeof rawPercent === 'number' && Number.isFinite(rawPercent) && rawPercent >= 0
428
+ ? rawPercent
429
+ : tokens === null
430
+ ? null
431
+ : (tokens / contextWindow) * 100;
432
+ return { tokens, contextWindow, percent };
433
+ }
434
+ function parseContextUsageXml(xml) {
435
+ const readNumber = (tag) => {
436
+ const match = new RegExp(`<${tag}>(.*?)</${tag}>`, 'i').exec(xml);
437
+ if (!match)
438
+ return undefined;
439
+ const raw = match[1]?.trim();
440
+ if (raw === 'null' || raw === '?')
441
+ return null;
442
+ const parsed = Number(raw);
443
+ return Number.isFinite(parsed) ? parsed : undefined;
444
+ };
445
+ const tokens = readNumber('tokens');
446
+ const contextWindow = readNumber('context-window') ?? readNumber('contextWindow');
447
+ const percent = readNumber('percent');
448
+ return normalizeContextUsage({ tokens, contextWindow, percent });
449
+ }
450
+ function nonNegativeInteger(value) {
451
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
452
+ }
453
+ function normalizeModel(value) {
454
+ if (typeof value !== 'string')
455
+ return undefined;
456
+ const trimmed = value.trim();
457
+ if (!trimmed)
458
+ return undefined;
459
+ return trimmed.length > 120 ? trimmed.slice(0, 120) : trimmed;
460
+ }
461
+ function normalizeTokenUsage(value) {
462
+ if (!isJsonObject(value))
463
+ return undefined;
464
+ const input = value;
465
+ const usage = {
466
+ input: nonNegativeInteger(input.input),
467
+ output: nonNegativeInteger(input.output),
468
+ cacheRead: nonNegativeInteger(input.cacheRead),
469
+ cacheWrite: nonNegativeInteger(input.cacheWrite),
470
+ totalTokens: nonNegativeInteger(input.totalTokens),
471
+ };
472
+ if (usage.totalTokens <= 0)
473
+ usage.totalTokens = usage.input + usage.output + usage.cacheRead + usage.cacheWrite;
474
+ const rawCostTotal = input.costTotal;
475
+ if (typeof rawCostTotal === 'number' && Number.isFinite(rawCostTotal) && rawCostTotal >= 0)
476
+ usage.costTotal = rawCostTotal;
477
+ return usage.totalTokens > 0 ? usage : undefined;
478
+ }
479
+ function normalizeToolUsage(value) {
480
+ if (!isJsonObject(value))
481
+ return undefined;
482
+ const input = value;
483
+ const byName = {};
484
+ const rawByName = input.byName;
485
+ if (isJsonObject(rawByName)) {
486
+ for (const [name, count] of Object.entries(rawByName)) {
487
+ const normalized = nonNegativeInteger(count);
488
+ if (normalized > 0)
489
+ byName[name] = normalized;
490
+ }
491
+ }
492
+ const byNameTotal = Object.values(byName).reduce((sum, count) => sum + count, 0);
493
+ const failed = nonNegativeInteger(input.failed);
494
+ const total = Math.max(nonNegativeInteger(input.total), byNameTotal, failed);
495
+ return total > 0 || failed > 0 ? { total, failed, byName } : undefined;
496
+ }
497
+ function noopOnChange() {
498
+ return undefined;
499
+ }
500
+ /**
501
+ * Deliver the delegate prompt bytes over stdin.
502
+ *
503
+ * A failure to deliver the seed is loud: the caller terminates the task rather
504
+ * than letting a child run without the context it was supposed to receive.
505
+ */
506
+ function writeDelegateStdin(child, bytes, onError) {
507
+ const stdin = child.stdin;
508
+ if (stdin === undefined || stdin === null) {
509
+ onError(new Error('delegate child stdin pipe is unavailable'));
510
+ return;
511
+ }
512
+ stdin.once('error', onError);
513
+ stdin.write(bytes, (error) => {
514
+ if (error !== undefined && error !== null) {
515
+ onError(error);
516
+ return;
517
+ }
518
+ stdin.end();
519
+ });
520
+ }
521
+ export class BackgroundTaskRegistry {
522
+ tasks = new Map();
523
+ runtimeDir;
524
+ shuttingDown = false;
525
+ taskAdmissionsClosed = false;
526
+ activeTaskAdmissions = new Set();
527
+ taskAdmissionDrainWaiters = new Set();
528
+ terminalPublicationClosed = false;
529
+ terminalPublicationCloseReason;
530
+ terminalPublicationClosedSignal;
531
+ resolveTerminalPublicationClosedSignal = () => { };
532
+ spawn;
533
+ killProcess;
534
+ killTree;
535
+ platform;
536
+ env;
537
+ shellPolicy;
538
+ shellPolicyEnv;
539
+ makeTaskIdFn;
540
+ now;
541
+ maxOutputBytes;
542
+ maxRecentTasks;
543
+ killGraceMs;
544
+ stopWaitMs;
545
+ taskAdmissionTimeoutMs;
546
+ attestedGitKillGraceMs;
547
+ attestedGitMaxOutputBytes;
548
+ attestedGitSpawn;
549
+ attestedRuntimePromise;
550
+ logger;
551
+ onChange;
552
+ sendCompletionNotification;
553
+ publishTerminalSnapshot;
554
+ posixProcessGroupKillStates = new WeakMap();
555
+ windowsKillStates = new WeakMap();
556
+ terminalPublicationAbandonSignals = new WeakMap();
557
+ reloadShellOwner;
558
+ reloadShellLease;
559
+ reloadShellIdentity;
560
+ constructor(options) {
561
+ this.terminalPublicationClosedSignal = new Promise((resolve) => {
562
+ this.resolveTerminalPublicationClosedSignal = resolve;
563
+ });
564
+ this.spawn =
565
+ options.spawn ?? ((command, args, spawnOptions) => nodeSpawn(command, args, spawnOptions));
566
+ this.killProcess = options.killProcess ?? process.kill.bind(process);
567
+ this.platform = options.platform ?? process.platform;
568
+ this.env = options.env ?? process.env;
569
+ this.shellPolicy = options.shellPolicy;
570
+ this.shellPolicyEnv = options.shellPolicy === undefined ? { ...this.env } : undefined;
571
+ const taskkillEnv = this.env;
572
+ this.killTree =
573
+ options.killTree ??
574
+ ((pid, phase, signal) => {
575
+ const taskkillOptions = signal === undefined ? { env: taskkillEnv } : { env: taskkillEnv, signal };
576
+ return runWindowsTaskkill(pid, phase, taskkillOptions);
577
+ });
578
+ this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
579
+ this.now = options.now ?? Date.now;
580
+ this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
581
+ this.maxRecentTasks = options.maxRecentTasks ?? MAX_RECENT_TASKS;
582
+ this.killGraceMs = options.killGraceMs ?? KILL_GRACE_MS;
583
+ this.stopWaitMs = options.stopWaitMs ?? STOP_WAIT_MS;
584
+ this.taskAdmissionTimeoutMs = BackgroundTaskRegistry.positiveTimeout(options.taskAdmissionTimeoutMs, TASK_ADMISSION_TIMEOUT_MS, 'taskAdmissionTimeoutMs');
585
+ this.attestedGitKillGraceMs = BackgroundTaskRegistry.positiveTimeout(options.attestedGitKillGraceMs, ATTESTED_GIT_KILL_GRACE_MS, 'attestedGitKillGraceMs');
586
+ this.attestedGitMaxOutputBytes = BackgroundTaskRegistry.positiveTimeout(options.attestedGitMaxOutputBytes, ATTESTED_GIT_MAX_OUTPUT_BYTES, 'attestedGitMaxOutputBytes');
587
+ this.attestedGitSpawn = options.attestedGitSpawn;
588
+ this.logger = options.logger ?? console;
589
+ this.onChange = options.onChange ?? noopOnChange;
590
+ this.sendCompletionNotification = options.sendCompletionNotification;
591
+ this.publishTerminalSnapshot = options.publishTerminal ?? noopOnChange;
592
+ this.reloadShellOwner = options.reloadShellOwner;
593
+ }
594
+ isShuttingDown() {
595
+ return this.shuttingDown;
596
+ }
597
+ resolvedShellPolicy() {
598
+ const existing = this.shellPolicy;
599
+ if (existing !== undefined)
600
+ return existing;
601
+ const resolved = resolveShellPolicy(this.platform, this.shellPolicyEnv ?? this.env, process.cwd());
602
+ this.shellPolicy = resolved;
603
+ return resolved;
604
+ }
605
+ loadAttestedRuntime() {
606
+ const existing = this.attestedRuntimePromise;
607
+ if (existing !== undefined)
608
+ return existing;
609
+ const loading = import('./attested-pi-run.js');
610
+ this.attestedRuntimePromise = loading;
611
+ return loading;
612
+ }
613
+ static positiveTimeout(value, fallback, label) {
614
+ const candidate = value ?? fallback;
615
+ if (!Number.isFinite(candidate) || candidate <= 0) {
616
+ throw new Error(`${label} must be a positive finite number`);
617
+ }
618
+ return Math.max(1, Math.floor(candidate));
619
+ }
620
+ beginTaskAdmission(kind) {
621
+ this.assertTaskAdmissionOpen(kind);
622
+ const controller = new AbortController();
623
+ const admission = {
624
+ kind,
625
+ controller,
626
+ deadlineAt: Date.now() + this.taskAdmissionTimeoutMs,
627
+ timeoutMs: this.taskAdmissionTimeoutMs,
628
+ timeoutHandle: undefined,
629
+ released: false,
630
+ };
631
+ admission.timeoutHandle = setTimeout(() => {
632
+ if (admission.released || admission.controller.signal.aborted)
633
+ return;
634
+ admission.controller.abort(new BackgroundTaskAdmissionTimeoutError(admission.kind, admission.timeoutMs));
635
+ }, admission.timeoutMs);
636
+ this.activeTaskAdmissions.add(admission);
637
+ return admission;
638
+ }
639
+ releaseTaskAdmission(admission) {
640
+ if (admission.released)
641
+ return;
642
+ admission.released = true;
643
+ if (admission.timeoutHandle !== undefined) {
644
+ clearTimeout(admission.timeoutHandle);
645
+ admission.timeoutHandle = undefined;
646
+ }
647
+ this.activeTaskAdmissions.delete(admission);
648
+ if (this.activeTaskAdmissions.size !== 0)
649
+ return;
650
+ for (const resolve of this.taskAdmissionDrainWaiters)
651
+ resolve();
652
+ this.taskAdmissionDrainWaiters.clear();
653
+ }
654
+ taskAdmissionError(admission) {
655
+ const reason = admission.controller.signal.reason;
656
+ if (reason instanceof Error)
657
+ return reason;
658
+ if (this.shuttingDown || this.taskAdmissionsClosed) {
659
+ return new BackgroundTaskAdmissionClosedError(admission.kind);
660
+ }
661
+ return new BackgroundTaskAdmissionTimeoutError(admission.kind, admission.timeoutMs);
662
+ }
663
+ surfacedTaskAdmissionError(admission, ...details) {
664
+ const primary = this.taskAdmissionError(admission);
665
+ const meaningful = details.filter((detail) => {
666
+ if (detail === undefined || detail === primary)
667
+ return false;
668
+ if (typeof detail !== 'object' || detail === null)
669
+ return true;
670
+ return (Reflect.get(detail, 'name') !== 'AbortError' &&
671
+ Reflect.get(detail, 'code') !== Reflect.get(primary, 'code'));
672
+ });
673
+ if (meaningful.length === 0)
674
+ return primary;
675
+ return new AggregateError([primary, ...meaningful], `${primary.message}; admission cancellation or cleanup reported additional failures: ${meaningful.map(BackgroundTaskRegistry.errorMessage).join('; ')}`);
676
+ }
677
+ assertTaskAdmissionOpen(kind, admission) {
678
+ if (admission?.controller.signal.aborted === true)
679
+ throw this.taskAdmissionError(admission);
680
+ if (this.shuttingDown || this.taskAdmissionsClosed) {
681
+ throw new BackgroundTaskAdmissionClosedError(kind);
682
+ }
683
+ }
684
+ async awaitTaskAdmissionBoundary(promise, admission) {
685
+ try {
686
+ const value = await promise;
687
+ this.assertTaskAdmissionOpen(admission.kind, admission);
688
+ return value;
689
+ }
690
+ catch (error) {
691
+ if (admission.controller.signal.aborted && typeof error === 'object' && error !== null) {
692
+ const isPlainAbort = Reflect.get(error, 'name') === 'AbortError';
693
+ const isCleanDurableCancellation = Reflect.get(error, 'code') === 'durable_file_cancelled' &&
694
+ Reflect.get(error, 'renameCompleted') !== true &&
695
+ Array.isArray(Reflect.get(error, 'cleanupFailures')) &&
696
+ Reflect.get(error, 'cleanupFailures').length === 0;
697
+ if (isPlainAbort || isCleanDurableCancellation) {
698
+ throw this.taskAdmissionError(admission);
699
+ }
700
+ }
701
+ throw error;
702
+ }
703
+ }
704
+ closeTaskAdmissions() {
705
+ if (this.taskAdmissionsClosed)
706
+ return;
707
+ this.taskAdmissionsClosed = true;
708
+ for (const admission of this.activeTaskAdmissions) {
709
+ if (!admission.controller.signal.aborted) {
710
+ admission.controller.abort(new BackgroundTaskAdmissionClosedError(admission.kind));
711
+ }
712
+ }
713
+ }
714
+ waitForTaskAdmissions() {
715
+ if (this.activeTaskAdmissions.size === 0)
716
+ return Promise.resolve();
717
+ return new Promise((resolve) => {
718
+ this.taskAdmissionDrainWaiters.add(resolve);
719
+ });
720
+ }
721
+ setShuttingDown(value) {
722
+ if (value) {
723
+ this.shuttingDown = true;
724
+ this.closeTaskAdmissions();
725
+ this.closeTerminalPublication('registry_shutdown');
726
+ return;
727
+ }
728
+ // Publication and admission closure belong to one extension activation and
729
+ // are one-way. Pi session replacement creates a fresh registry; an old
730
+ // registry must not be reopened by a late lifecycle continuation.
731
+ if (!this.terminalPublicationClosed && !this.taskAdmissionsClosed)
732
+ this.shuttingDown = false;
733
+ }
734
+ closeTerminalPublication(reason) {
735
+ if (!this.terminalPublicationClosed) {
736
+ this.terminalPublicationClosed = true;
737
+ this.terminalPublicationCloseReason = reason;
738
+ this.resolveTerminalPublicationClosedSignal(reason);
739
+ }
740
+ const effectiveReason = this.terminalPublicationCloseReason ?? reason;
741
+ for (const task of this.tasks.values()) {
742
+ if (task.terminalPublicationState === 'pending' && task.terminalEmitInFlight === true) {
743
+ // A synchronous listener can close the service while emit() is still on
744
+ // the stack. Dispose queued work now, but let the emitter's return/throw
745
+ // settle the in-flight attempt exactly once.
746
+ if (task.terminalPublishRetryHandle !== undefined) {
747
+ clearTimeout(task.terminalPublishRetryHandle);
748
+ task.terminalPublishRetryHandle = undefined;
749
+ }
750
+ task.terminalPublicationGate = undefined;
751
+ continue;
752
+ }
753
+ const shouldLog = task.terminalPublicationState === 'pending' &&
754
+ task.status !== 'running' &&
755
+ (task.terminalPublishAttempts > 0 ||
756
+ task.terminalPublishRetryHandle !== undefined ||
757
+ task.terminalPublicationGate !== undefined);
758
+ this.abandonTerminalPublication(task, effectiveReason, undefined, shouldLog);
759
+ }
760
+ this.pruneOldTasks();
761
+ }
762
+ allTasks() {
763
+ return [...this.tasks.values()];
764
+ }
765
+ snapshot(task) {
766
+ return snapshot(task);
767
+ }
768
+ hasCurrentReloadLease() {
769
+ const lease = this.reloadShellLease;
770
+ return lease !== undefined && this.reloadShellOwner?.isCurrentLease(lease) === true;
771
+ }
772
+ async stageReloadActivation(claim) {
773
+ if (claim.protocol !== RELOAD_SHELL_OWNER_PROTOCOL) {
774
+ throw new ReloadSurvivalError('pi_bg_reload_owner_protocol_incompatible', 'activation claim does not use the supported reload shell owner protocol');
775
+ }
776
+ const staged = [];
777
+ const ids = new Set();
778
+ for (const execution of claim.executions) {
779
+ if (execution.protocol !== RELOAD_SHELL_OWNER_PROTOCOL ||
780
+ execution.task.reloadExecution !== execution ||
781
+ execution.task.surviveReload !== true) {
782
+ throw new ReloadSurvivalError('pi_bg_reload_owner_protocol_incompatible', 'activation claim contains an incompatible reload shell execution');
783
+ }
784
+ if (ids.has(execution.task.id) || this.tasks.has(execution.task.id)) {
785
+ throw new ReloadSurvivalError('pi_bg_reload_owner_activation_conflict', `claimed task id ${execution.task.id} conflicts with the fresh registry`);
786
+ }
787
+ ids.add(execution.task.id);
788
+ }
789
+ try {
790
+ for (const execution of claim.executions) {
791
+ const task = execution.task;
792
+ task.reloadHostDeliveryInFlight = false;
793
+ task.reloadHostDeliverySettled = false;
794
+ task.reloadHostNotificationSettled = false;
795
+ execution.updateLeaseAudit(claim.generation, (task.reloadSurvival?.handoffCount ?? 0) + 1);
796
+ this.tasks.set(task.id, task);
797
+ staged.push(execution);
798
+ }
799
+ await Promise.all(staged.map(async (execution) => this.writeMetadata(execution.task)));
800
+ }
801
+ catch (error) {
802
+ for (const execution of staged)
803
+ this.tasks.delete(execution.task.id);
804
+ throw error;
805
+ }
806
+ let boundLease;
807
+ return {
808
+ activationNonce: claim.activationNonce,
809
+ onBound: (lease) => {
810
+ if (lease.activationNonce !== claim.activationNonce ||
811
+ lease.generation !== claim.generation ||
812
+ lease.identityKey !== claim.identityKey) {
813
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', 'committed lease does not match its staged activation claim');
814
+ }
815
+ boundLease = lease;
816
+ this.reloadShellLease = lease;
817
+ this.reloadShellIdentity = claim.identity;
818
+ },
819
+ onChanged: (execution) => {
820
+ const lease = boundLease;
821
+ if (!this.ownsReloadExecution(execution, lease))
822
+ return;
823
+ this.onChange();
824
+ },
825
+ onTerminal: (execution) => {
826
+ const lease = boundLease;
827
+ if (!this.ownsReloadExecution(execution, lease))
828
+ return;
829
+ void this.deliverReloadTerminal(execution, lease);
830
+ },
831
+ };
832
+ }
833
+ abortReloadActivation(claim) {
834
+ for (const execution of claim.executions) {
835
+ const task = execution.task;
836
+ if (this.tasks.get(task.id) !== task)
837
+ continue;
838
+ if (task.terminalPublishRetryHandle !== undefined) {
839
+ clearTimeout(task.terminalPublishRetryHandle);
840
+ task.terminalPublishRetryHandle = undefined;
841
+ }
842
+ task.terminalPublicationGate = undefined;
843
+ task.terminalPublishInFlight = false;
844
+ this.tasks.delete(task.id);
845
+ }
846
+ if (this.reloadShellLease?.activationNonce === claim.activationNonce) {
847
+ this.reloadShellLease = undefined;
848
+ this.reloadShellIdentity = undefined;
849
+ }
850
+ }
851
+ prepareReloadHandoff(lease) {
852
+ if (this.reloadShellOwner === undefined || this.reloadShellLease !== lease) {
853
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', 'registry does not own the requested reload activation lease');
854
+ }
855
+ const executions = this.reloadShellOwner.beginReloadHandoff(lease);
856
+ const tasks = [];
857
+ for (const execution of executions) {
858
+ const task = execution.task;
859
+ if (this.tasks.get(task.id) !== task) {
860
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', `registry no longer owns survivor ${task.id}`);
861
+ }
862
+ if (task.terminalPublishRetryHandle !== undefined) {
863
+ clearTimeout(task.terminalPublishRetryHandle);
864
+ task.terminalPublishRetryHandle = undefined;
865
+ }
866
+ task.terminalPublicationGate = undefined;
867
+ task.terminalPublishInFlight = false;
868
+ task.reloadHostDeliveryInFlight = false;
869
+ task.reloadHostDeliverySettled = false;
870
+ task.reloadHostNotificationSettled = false;
871
+ this.tasks.delete(task.id);
872
+ tasks.push(task);
873
+ }
874
+ this.reloadShellLease = undefined;
875
+ this.reloadShellIdentity = undefined;
876
+ return Object.freeze(tasks);
877
+ }
878
+ async waitForReloadHostSettlement(timeoutMs = this.stopWaitMs) {
879
+ const deadline = Date.now() + timeoutMs;
880
+ while (true) {
881
+ const unsettled = [...this.tasks.values()].filter((task) => task.reloadExecution?.phase === 'terminal' &&
882
+ (task.reloadHostNotificationSettled !== true ||
883
+ task.terminalPublicationState === 'pending'));
884
+ if (unsettled.length === 0)
885
+ return;
886
+ const remaining = deadline - Date.now();
887
+ if (remaining <= 0) {
888
+ throw new Error(`Timed out waiting for reload shell host settlement: ${unsettled.map((task) => task.id).join(', ')}`);
889
+ }
890
+ await new Promise((resolve) => setTimeout(resolve, Math.min(10, remaining)));
891
+ }
892
+ }
893
+ releaseReloadActivation(lease) {
894
+ if (this.reloadShellOwner === undefined)
895
+ return;
896
+ if (this.reloadShellLease !== lease || !this.reloadShellOwner.isCurrentLease(lease))
897
+ return;
898
+ this.reloadShellOwner.releaseActivation(lease);
899
+ this.reloadShellLease = undefined;
900
+ this.reloadShellIdentity = undefined;
901
+ }
902
+ currentReloadLease() {
903
+ return this.hasCurrentReloadLease() ? this.reloadShellLease : undefined;
904
+ }
905
+ ownsReloadExecution(execution, lease) {
906
+ return (lease !== undefined &&
907
+ this.reloadShellLease === lease &&
908
+ this.reloadShellOwner?.isCurrentLease(lease) === true &&
909
+ this.tasks.get(execution.task.id) === execution.task &&
910
+ execution.task.reloadExecution === execution);
911
+ }
912
+ async ensureRuntimeDir(ctx) {
913
+ if (this.runtimeDir)
914
+ return this.runtimeDir;
915
+ const sessionId = sanitizePathSegment(ctx.sessionId ?? `session-${String(process.pid)}`);
916
+ const runId = `${sessionId}-${String(process.pid)}`;
917
+ const runtimeDirAbs = join(ctx.cwd, '.pi', 'tasks', runId);
918
+ const runtimeDirDisplay = join('.pi', 'tasks', runId);
919
+ await mkdir(runtimeDirAbs, { recursive: true });
920
+ this.runtimeDir = { abs: runtimeDirAbs, display: runtimeDirDisplay };
921
+ return this.runtimeDir;
922
+ }
923
+ async destroyTaskStream(task) {
924
+ const stream = task.stream;
925
+ if (stream === undefined || stream.closed)
926
+ return;
927
+ await new Promise((resolve) => {
928
+ const closed = () => {
929
+ stream.off('close', closed);
930
+ resolve();
931
+ };
932
+ stream.once('close', closed);
933
+ if (!stream.destroyed)
934
+ stream.destroy();
935
+ if (stream.closed)
936
+ closed();
937
+ });
938
+ }
939
+ async discardUnspawnedTask(task, paths) {
940
+ this.tasks.delete(task.id);
941
+ task.finalized = true;
942
+ task.status = 'failed';
943
+ if (task.timeoutHandle !== undefined)
944
+ clearTimeout(task.timeoutHandle);
945
+ if (task.killEscalationTimer !== undefined)
946
+ clearTimeout(task.killEscalationTimer);
947
+ await this.destroyTaskStream(task);
948
+ const removals = await Promise.allSettled(paths.map((path) => rm(path, { force: true })));
949
+ const failures = [];
950
+ for (let index = 0; index < removals.length; index++) {
951
+ const result = removals[index];
952
+ if (result?.status !== 'rejected')
953
+ continue;
954
+ const path = paths[index] ?? '<unknown admission artifact>';
955
+ failures.push(new Error(`Failed to remove interrupted admission artifact ${path}: ${BackgroundTaskRegistry.errorMessage(result.reason)}`));
956
+ }
957
+ if (failures.length > 0) {
958
+ throw new AggregateError(failures, 'Interrupted task admission artifact cleanup failed');
959
+ }
960
+ }
961
+ attestedGitOptions(admission) {
962
+ return {
963
+ signal: admission?.controller.signal,
964
+ deadlineAt: admission?.deadlineAt ?? Date.now() + this.taskAdmissionTimeoutMs,
965
+ killProcess: this.killProcess,
966
+ killTree: this.killTree,
967
+ platform: this.platform,
968
+ env: this.env,
969
+ killGraceMs: this.attestedGitKillGraceMs,
970
+ maxOutputBytes: this.attestedGitMaxOutputBytes,
971
+ ...(this.attestedGitSpawn === undefined ? {} : { spawn: this.attestedGitSpawn }),
972
+ };
973
+ }
974
+ captureSpawnedChild(task, child) {
975
+ task.child = child;
976
+ task.pid = child.pid;
977
+ if (this.platform !== 'win32' &&
978
+ child.pid !== undefined &&
979
+ Number.isSafeInteger(child.pid) &&
980
+ child.pid > 0) {
981
+ // Capture detached-group ownership once, directly from this spawn. Never
982
+ // reconstruct signal authority from mutable task metadata or a later PID.
983
+ task.ownedPosixProcessGroupId = child.pid;
984
+ }
985
+ }
986
+ bindOwnedTaskToAdmission(task, admission) {
987
+ const cancelOwnedTask = () => {
988
+ this.stopOwnedTaskAfterAdmissionCancellation(task, this.taskAdmissionError(admission));
989
+ };
990
+ admission.controller.signal.addEventListener('abort', cancelOwnedTask, { once: true });
991
+ if (admission.controller.signal.aborted)
992
+ cancelOwnedTask();
993
+ return () => {
994
+ admission.controller.signal.removeEventListener('abort', cancelOwnedTask);
995
+ };
996
+ }
997
+ stopOwnedTaskAfterAdmissionCancellation(task, error) {
998
+ if (task.status !== 'running')
999
+ return;
1000
+ task.killKind = error instanceof BackgroundTaskAdmissionTimeoutError ? 'timeout' : 'shutdown';
1001
+ task.error = error.message;
1002
+ try {
1003
+ this.requestKill(task, 'SIGTERM');
1004
+ }
1005
+ catch (killError) {
1006
+ this.logger.error(`[background-tasks] failed to stop ${task.id} after admission cancellation:`, killError);
1007
+ }
1008
+ }
1009
+ async startTask(ctx, command, options = {}) {
1010
+ const hasSurvival = Object.prototype.hasOwnProperty.call(options, 'surviveReload');
1011
+ if (hasSurvival && typeof options.surviveReload !== 'boolean') {
1012
+ throw new ReloadSurvivalError('pi_bg_survive_reload_invalid', 'surviveReload must be a boolean when present');
1013
+ }
1014
+ const surviveReload = options.surviveReload === true;
1015
+ if (surviveReload && options.isAgent === true) {
1016
+ throw new ReloadSurvivalError('pi_bg_survive_reload_requires_non_agent', 'surviveReload requires isAgent:false');
1017
+ }
1018
+ const reloadLease = surviveReload ? this.currentReloadLease() : undefined;
1019
+ if (surviveReload && reloadLease === undefined) {
1020
+ throw new ReloadSurvivalError('pi_bg_reload_owner_unavailable', 'no successfully bound same-process reload owner activation is available');
1021
+ }
1022
+ const admission = this.beginTaskAdmission('a background task');
1023
+ try {
1024
+ if (surviveReload && reloadLease !== undefined) {
1025
+ return await this.startReloadableTaskAdmitted(ctx, command, options, admission, reloadLease);
1026
+ }
1027
+ return await this.startTaskAdmitted(ctx, command, options, admission);
1028
+ }
1029
+ finally {
1030
+ this.releaseTaskAdmission(admission);
1031
+ }
1032
+ }
1033
+ async startReloadableTaskAdmitted(ctx, command, options, admission, lease) {
1034
+ const normalizedCommand = command.trim();
1035
+ if (!normalizedCommand)
1036
+ throw new Error('Background command is empty');
1037
+ if (options.isAgent === true) {
1038
+ throw new ReloadSurvivalError('pi_bg_survive_reload_requires_non_agent', 'surviveReload requires isAgent:false');
1039
+ }
1040
+ if (this.reloadShellOwner === undefined ||
1041
+ this.reloadShellIdentity === undefined ||
1042
+ this.reloadShellLease !== lease ||
1043
+ !this.reloadShellOwner.isCurrentLease(lease)) {
1044
+ throw new ReloadSurvivalError('pi_bg_reload_owner_unavailable', 'reload owner activation became unavailable before launch');
1045
+ }
1046
+ if (ctx.sessionId !== this.reloadShellIdentity.sessionId) {
1047
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', 'launch context session id does not match the bound reload owner identity');
1048
+ }
1049
+ this.assertTaskAdmissionOpen('a background task', admission);
1050
+ const shellPolicy = this.resolvedShellPolicy();
1051
+ const invocation = shellInvocationForPolicy(normalizedCommand, shellPolicy);
1052
+ const dir = await this.awaitTaskAdmissionBoundary(this.ensureRuntimeDir(ctx), admission);
1053
+ this.assertTaskAdmissionOpen('a background task', admission);
1054
+ if (this.reloadShellLease !== lease || !this.reloadShellOwner.isCurrentLease(lease)) {
1055
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', 'reload owner activation changed during task preflight');
1056
+ }
1057
+ const id = this.makeTaskIdFn();
1058
+ const outputAbsPath = join(dir.abs, `${id}.output`);
1059
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
1060
+ const outputPath = join(dir.display, `${id}.output`);
1061
+ const timeoutSeconds = typeof options.timeoutSeconds === 'number' &&
1062
+ Number.isFinite(options.timeoutSeconds) &&
1063
+ options.timeoutSeconds > 0
1064
+ ? Math.floor(options.timeoutSeconds)
1065
+ : undefined;
1066
+ const taskName = normalizeTaskName(options.name) ??
1067
+ normalizeTaskName(options.description) ??
1068
+ deriveTaskNameFromCommand(normalizedCommand);
1069
+ const trimmedDescription = options.description?.trim();
1070
+ const description = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : undefined;
1071
+ const task = {
1072
+ id,
1073
+ name: taskName,
1074
+ command: normalizedCommand,
1075
+ description,
1076
+ status: 'running',
1077
+ outputPath,
1078
+ outputAbsPath,
1079
+ metadataAbsPath,
1080
+ cwd: ctx.cwd,
1081
+ startTime: this.now(),
1082
+ exitCode: undefined,
1083
+ pid: undefined,
1084
+ bytesWritten: 0,
1085
+ isAgent: false,
1086
+ surviveReload: true,
1087
+ notified: false,
1088
+ notifyOnCompletion: options.notifyOnCompletion ?? true,
1089
+ triggerOnCompletion: options.triggerOnCompletion ?? false,
1090
+ timeoutSeconds,
1091
+ terminalPublished: false,
1092
+ terminalPublicationState: 'pending',
1093
+ terminalPublishAttempts: 0,
1094
+ terminalPublicationGate: options.terminalPublicationGate,
1095
+ shellPolicy: shellPolicySnapshot(shellPolicy),
1096
+ waiters: [],
1097
+ };
1098
+ const launchNonce = randomBytes(16).toString('hex');
1099
+ this.assertTaskAdmissionOpen('a background task', admission);
1100
+ this.tasks.set(id, task);
1101
+ let execution;
1102
+ let registered = false;
1103
+ let committed = false;
1104
+ let abortListener;
1105
+ try {
1106
+ execution = createReloadableShellExecutionV1({
1107
+ task,
1108
+ identity: this.reloadShellIdentity,
1109
+ lease,
1110
+ launchNonce,
1111
+ invocation,
1112
+ spawn: this.spawn,
1113
+ killProcess: this.killProcess,
1114
+ killTree: this.killTree,
1115
+ platform: this.platform,
1116
+ env: this.env,
1117
+ maxOutputBytes: this.maxOutputBytes,
1118
+ killGraceMs: this.killGraceMs,
1119
+ stopWaitMs: this.stopWaitMs,
1120
+ now: this.now,
1121
+ logger: this.logger,
1122
+ });
1123
+ this.reloadShellOwner.registerExecution(lease, execution);
1124
+ registered = true;
1125
+ abortListener = () => {
1126
+ if (execution === undefined)
1127
+ return;
1128
+ const error = this.taskAdmissionError(admission);
1129
+ execution.failAdmission(error);
1130
+ const kind = error instanceof BackgroundTaskAdmissionTimeoutError ? 'timeout' : 'shutdown';
1131
+ void execution.requestStop(kind, error.message).catch((stopError) => {
1132
+ this.logger.error(`[background-tasks] failed to stop reloadable task ${task.id} after admission cancellation:`, stopError);
1133
+ });
1134
+ };
1135
+ admission.controller.signal.addEventListener('abort', abortListener, { once: true });
1136
+ if (admission.controller.signal.aborted)
1137
+ abortListener();
1138
+ await this.awaitTaskAdmissionBoundary(execution.commitInitialMetadata(admission.controller.signal), admission);
1139
+ this.assertTaskAdmissionOpen('a background task', admission);
1140
+ if (this.reloadShellLease !== lease || !this.reloadShellOwner.isCurrentLease(lease)) {
1141
+ throw new ReloadSurvivalError('pi_bg_reload_owner_stale_claim', 'reload owner activation changed before admission commit');
1142
+ }
1143
+ this.reloadShellOwner.markAdmissionCommitted(lease, execution);
1144
+ committed = true;
1145
+ this.onChange();
1146
+ return task;
1147
+ }
1148
+ catch (error) {
1149
+ const primary = admission.controller.signal.aborted
1150
+ ? this.taskAdmissionError(admission)
1151
+ : error instanceof Error
1152
+ ? error
1153
+ : new Error(String(error));
1154
+ execution?.failAdmission(primary);
1155
+ let cleanupError;
1156
+ if (execution !== undefined) {
1157
+ try {
1158
+ if (task.status === 'running') {
1159
+ await execution.requestStop(admission.controller.signal.aborted &&
1160
+ primary instanceof BackgroundTaskAdmissionTimeoutError
1161
+ ? 'timeout'
1162
+ : 'shutdown', primary.message);
1163
+ }
1164
+ }
1165
+ catch (stopError) {
1166
+ cleanupError = stopError;
1167
+ }
1168
+ }
1169
+ if (registered && !committed && execution !== undefined) {
1170
+ if (this.reloadShellOwner.isCurrentLease(lease)) {
1171
+ this.reloadShellOwner.releaseExecution(lease, execution);
1172
+ }
1173
+ }
1174
+ this.tasks.delete(task.id);
1175
+ if (execution === undefined) {
1176
+ const removals = await Promise.allSettled([
1177
+ rm(outputAbsPath, { force: true }),
1178
+ rm(metadataAbsPath, { force: true }),
1179
+ ]);
1180
+ const removalFailure = removals.find((result) => result.status === 'rejected');
1181
+ if (removalFailure?.status === 'rejected')
1182
+ cleanupError = removalFailure.reason;
1183
+ }
1184
+ if (admission.controller.signal.aborted) {
1185
+ throw this.surfacedTaskAdmissionError(admission, error, cleanupError);
1186
+ }
1187
+ if (cleanupError !== undefined) {
1188
+ throw new AggregateError([primary, cleanupError], `Failed to start reloadable background task and cleanup also failed: ${BackgroundTaskRegistry.errorMessage(cleanupError)}`);
1189
+ }
1190
+ throw new Error(`Failed to start background task: ${primary.message}`);
1191
+ }
1192
+ finally {
1193
+ if (abortListener !== undefined) {
1194
+ admission.controller.signal.removeEventListener('abort', abortListener);
1195
+ }
1196
+ }
1197
+ }
1198
+ async startTaskAdmitted(ctx, command, options, admission) {
1199
+ const normalizedCommand = command.trim();
1200
+ if (!normalizedCommand)
1201
+ throw new Error('Background command is empty');
1202
+ this.assertTaskAdmissionOpen('a background task', admission);
1203
+ const isAgent = options.isAgent ?? false;
1204
+ const shellPolicy = this.resolvedShellPolicy();
1205
+ const baseInvocation = shellInvocationForPolicy(normalizedCommand, shellPolicy);
1206
+ const piTelemetryRequested = isAgent && commandMayLaunchPiAgent(normalizedCommand, this.env);
1207
+ const piTelemetryLaunch = piTelemetryRequested && shellPolicy.supportsPosixFunctionWrapper
1208
+ ? resolvePiLaunch({ platform: this.platform })
1209
+ : undefined;
1210
+ const dir = await this.awaitTaskAdmissionBoundary(this.ensureRuntimeDir(ctx), admission);
1211
+ this.assertTaskAdmissionOpen('a background task', admission);
1212
+ const id = this.makeTaskIdFn();
1213
+ const outputAbsPath = join(dir.abs, `${id}.output`);
1214
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
1215
+ const outputPath = join(dir.display, `${id}.output`);
1216
+ let commandToSpawn = normalizedCommand;
1217
+ let wrapperAbsPath;
1218
+ try {
1219
+ if (piTelemetryRequested && shellPolicy.supportsPosixFunctionWrapper) {
1220
+ if (piTelemetryLaunch === undefined)
1221
+ throw new Error('Pi telemetry launch spec was not resolved');
1222
+ wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
1223
+ try {
1224
+ await writeFile(wrapperAbsPath, createPiTelemetryWrapperSource(buildModelWindowIndex(ctx), piTelemetryLaunch), { encoding: 'utf8', signal: admission.controller.signal });
1225
+ }
1226
+ catch (error) {
1227
+ if (admission.controller.signal.aborted)
1228
+ throw this.taskAdmissionError(admission);
1229
+ throw error;
1230
+ }
1231
+ this.assertTaskAdmissionOpen('a background task', admission);
1232
+ commandToSpawn = `pi() { ${shellQuote(process.execPath)} ${shellQuote(wrapperAbsPath)} "$@"; }\n${normalizedCommand}`;
1233
+ }
1234
+ }
1235
+ catch (error) {
1236
+ if (wrapperAbsPath !== undefined)
1237
+ await rm(wrapperAbsPath, { force: true });
1238
+ throw error;
1239
+ }
1240
+ const invocation = commandToSpawn === normalizedCommand
1241
+ ? baseInvocation
1242
+ : shellInvocationForPolicy(commandToSpawn, shellPolicy);
1243
+ const timeoutSeconds = typeof options.timeoutSeconds === 'number' &&
1244
+ Number.isFinite(options.timeoutSeconds) &&
1245
+ options.timeoutSeconds > 0
1246
+ ? Math.floor(options.timeoutSeconds)
1247
+ : undefined;
1248
+ const taskName = normalizeTaskName(options.name) ??
1249
+ normalizeTaskName(options.description) ??
1250
+ deriveTaskNameFromCommand(normalizedCommand);
1251
+ const trimmedDescription = options.description?.trim();
1252
+ const description = trimmedDescription && trimmedDescription.length > 0 ? trimmedDescription : undefined;
1253
+ const task = {
1254
+ id,
1255
+ name: taskName,
1256
+ command: normalizedCommand,
1257
+ description,
1258
+ status: 'running',
1259
+ outputPath,
1260
+ outputAbsPath,
1261
+ metadataAbsPath,
1262
+ cwd: ctx.cwd,
1263
+ startTime: this.now(),
1264
+ exitCode: undefined,
1265
+ pid: undefined,
1266
+ bytesWritten: 0,
1267
+ isAgent,
1268
+ surviveReload: false,
1269
+ notified: false,
1270
+ notifyOnCompletion: options.notifyOnCompletion ?? true,
1271
+ triggerOnCompletion: options.triggerOnCompletion ?? false,
1272
+ timeoutSeconds,
1273
+ terminalPublished: false,
1274
+ terminalPublicationState: 'pending',
1275
+ terminalPublishAttempts: 0,
1276
+ terminalPublicationGate: options.terminalPublicationGate,
1277
+ shellPolicy: shellPolicySnapshot(shellPolicy),
1278
+ waiters: [],
1279
+ };
1280
+ if (commandToSpawn !== normalizedCommand)
1281
+ task.telemetryWrapped = true;
1282
+ if (piTelemetryRequested && !shellPolicy.supportsPosixFunctionWrapper) {
1283
+ task.telemetryUnavailableReason =
1284
+ shellPolicy.dialect === 'cmd'
1285
+ ? WIN32_CMD_PI_TELEMETRY_UNAVAILABLE_REASON
1286
+ : NON_POSIX_SHELL_PI_TELEMETRY_UNAVAILABLE_REASON;
1287
+ }
1288
+ this.assertTaskAdmissionOpen('a background task', admission);
1289
+ this.tasks.set(id, task);
1290
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1291
+ task.stream = stream;
1292
+ stream.on('error', (error) => {
1293
+ task.error = `Output file write failed: ${error.message}`;
1294
+ if (task.status === 'running') {
1295
+ task.killKind = 'output_cap';
1296
+ try {
1297
+ this.requestKill(task, 'SIGTERM');
1298
+ }
1299
+ catch (killError) {
1300
+ void this.finalizeTask(task, 'failed', null, undefined, `${task.error}; kill failed: ${killError instanceof Error ? killError.message : String(killError)}`);
1301
+ }
1302
+ }
1303
+ });
1304
+ let unbindAdmissionCancellation = () => undefined;
1305
+ try {
1306
+ this.assertTaskAdmissionOpen('a background task', admission);
1307
+ const child = this.spawn(invocation.shell, invocation.args, {
1308
+ cwd: ctx.cwd,
1309
+ detached: this.platform !== 'win32',
1310
+ stdio: ['ignore', 'pipe', 'pipe'],
1311
+ env: this.env,
1312
+ windowsHide: true,
1313
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments,
1314
+ });
1315
+ this.captureSpawnedChild(task, child);
1316
+ child.stdout?.on('data', (data) => {
1317
+ this.appendChildOutput(task, data, 'stdout');
1318
+ });
1319
+ child.stderr?.on('data', (data) => {
1320
+ this.appendChildOutput(task, data, 'stderr');
1321
+ });
1322
+ child.on('error', (error) => {
1323
+ this.writeNotice(task, `\n[background task spawn error: ${error.message}]\n`);
1324
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
1325
+ });
1326
+ child.on('close', (code, signalName) => {
1327
+ let status;
1328
+ let error;
1329
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
1330
+ status = 'killed';
1331
+ }
1332
+ else if (task.killKind === 'timeout') {
1333
+ status = 'failed';
1334
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
1335
+ }
1336
+ else if (task.killKind === 'output_cap') {
1337
+ status = 'failed';
1338
+ error = task.error ?? `Output exceeded cap of ${formatSize(this.maxOutputBytes)}`;
1339
+ }
1340
+ else if ((code ?? 0) === 0) {
1341
+ status = 'completed';
1342
+ }
1343
+ else {
1344
+ status = 'failed';
1345
+ const exitCode = code === null ? 'null' : String(code);
1346
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
1347
+ }
1348
+ void this.finalizeTask(task, status, code, signalName, error);
1349
+ });
1350
+ if (timeoutSeconds !== undefined) {
1351
+ task.timeoutHandle = setTimeout(() => {
1352
+ if (task.status !== 'running')
1353
+ return;
1354
+ task.killKind = 'timeout';
1355
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
1356
+ this.writeNotice(task, `\n[background task timeout: ${task.error}]\n`);
1357
+ try {
1358
+ this.requestKill(task, 'SIGTERM');
1359
+ }
1360
+ catch (error) {
1361
+ void this.finalizeTask(task, 'failed', null, undefined, `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`);
1362
+ }
1363
+ }, timeoutSeconds * 1000);
1364
+ }
1365
+ unbindAdmissionCancellation = this.bindOwnedTaskToAdmission(task, admission);
1366
+ await this.awaitTaskAdmissionBoundary(this.writeMetadata(task, admission.controller.signal), admission);
1367
+ this.assertTaskAdmissionOpen('a background task', admission);
1368
+ this.onChange();
1369
+ return task;
1370
+ }
1371
+ catch (error) {
1372
+ if (admission.controller.signal.aborted) {
1373
+ const admissionError = this.taskAdmissionError(admission);
1374
+ let cleanupError;
1375
+ if (task.child === undefined) {
1376
+ try {
1377
+ await this.discardUnspawnedTask(task, [
1378
+ outputAbsPath,
1379
+ metadataAbsPath,
1380
+ ...(wrapperAbsPath === undefined ? [] : [wrapperAbsPath]),
1381
+ ]);
1382
+ }
1383
+ catch (cleanupFailure) {
1384
+ cleanupError = cleanupFailure;
1385
+ }
1386
+ }
1387
+ else {
1388
+ this.stopOwnedTaskAfterAdmissionCancellation(task, admissionError);
1389
+ }
1390
+ throw this.surfacedTaskAdmissionError(admission, error, cleanupError);
1391
+ }
1392
+ const message = error instanceof Error ? error.message : String(error);
1393
+ this.writeNotice(task, `\n[background task spawn exception: ${message}]\n`);
1394
+ await this.finalizeTask(task, 'failed', null, undefined, message);
1395
+ throw new Error(`Failed to start background task: ${message}`);
1396
+ }
1397
+ finally {
1398
+ unbindAdmissionCancellation();
1399
+ }
1400
+ }
1401
+ /**
1402
+ * Track an in-process asynchronous workflow through the same durable task,
1403
+ * notification, status, log, and cancellation surfaces as child processes.
1404
+ * The supplied completion promise must own all workflow cleanup before it
1405
+ * settles; terminal publication happens only after that settlement.
1406
+ */
1407
+ async startManagedTask(ctx, request) {
1408
+ rejectSurvivalForTaskKind(request, 'managed background tasks');
1409
+ let admission;
1410
+ try {
1411
+ admission = this.beginTaskAdmission('a managed background task');
1412
+ return await this.startManagedTaskAdmitted(ctx, request, admission);
1413
+ }
1414
+ catch (error) {
1415
+ const admissionError = admission?.controller.signal.aborted === true
1416
+ ? this.taskAdmissionError(admission)
1417
+ : error instanceof BackgroundTaskAdmissionClosedError
1418
+ ? error
1419
+ : undefined;
1420
+ if (admissionError !== undefined) {
1421
+ const task = admission === undefined ? undefined : this.tasks.get(request.id);
1422
+ if (task === undefined) {
1423
+ try {
1424
+ request.cancel();
1425
+ }
1426
+ catch (cancelError) {
1427
+ this.logger.error(`[background-tasks] managed preflight cancellation failed for ${request.id}:`, cancelError);
1428
+ }
1429
+ // The workflow promise owns child/artifact cleanup. Do not release a
1430
+ // pre-insertion admission while that cleanup can still run late.
1431
+ await request.completion.then(() => undefined, () => undefined);
1432
+ }
1433
+ else {
1434
+ this.stopOwnedTaskAfterAdmissionCancellation(task, admissionError);
1435
+ await request.completion.then(() => undefined, () => undefined);
1436
+ }
1437
+ if (admission !== undefined) {
1438
+ throw this.surfacedTaskAdmissionError(admission, error);
1439
+ }
1440
+ throw admissionError;
1441
+ }
1442
+ throw error;
1443
+ }
1444
+ finally {
1445
+ if (admission !== undefined)
1446
+ this.releaseTaskAdmission(admission);
1447
+ }
1448
+ }
1449
+ async startManagedTaskAdmitted(ctx, request, admission) {
1450
+ this.assertTaskAdmissionOpen('a managed background task', admission);
1451
+ if (!/^[a-zA-Z0-9_.-]+$/u.test(request.id))
1452
+ throw new Error(`Managed background task id is invalid: ${request.id}`);
1453
+ if (this.tasks.has(request.id))
1454
+ throw new Error(`Background task id already exists: ${request.id}`);
1455
+ const dir = await this.awaitTaskAdmissionBoundary(this.ensureRuntimeDir(ctx), admission);
1456
+ this.assertTaskAdmissionOpen('a managed background task', admission);
1457
+ const outputAbsPath = join(dir.abs, `${request.id}.output`);
1458
+ const metadataAbsPath = join(dir.abs, `${request.id}.json`);
1459
+ const outputPath = join(dir.display, `${request.id}.output`);
1460
+ const task = {
1461
+ id: request.id,
1462
+ name: normalizeTaskName(request.name) ?? 'Managed background task',
1463
+ command: request.command,
1464
+ description: request.description,
1465
+ status: 'running',
1466
+ outputPath,
1467
+ outputAbsPath,
1468
+ metadataAbsPath,
1469
+ cwd: ctx.cwd,
1470
+ startTime: this.now(),
1471
+ exitCode: undefined,
1472
+ pid: undefined,
1473
+ bytesWritten: 0,
1474
+ isAgent: request.isAgent,
1475
+ surviveReload: false,
1476
+ notified: false,
1477
+ notifyOnCompletion: request.notifyOnCompletion,
1478
+ triggerOnCompletion: request.triggerOnCompletion,
1479
+ fusion: request.fusion,
1480
+ managedCancel: request.cancel,
1481
+ managedStopWaitMs: request.stopWaitMs,
1482
+ terminalPublished: false,
1483
+ terminalPublicationState: 'pending',
1484
+ terminalPublishAttempts: 0,
1485
+ terminalPublicationGate: request.terminalPublicationGate,
1486
+ waiters: [],
1487
+ };
1488
+ this.assertTaskAdmissionOpen('a managed background task', admission);
1489
+ this.tasks.set(task.id, task);
1490
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1491
+ task.stream = stream;
1492
+ stream.on('error', (error) => {
1493
+ task.error = `Output file write failed: ${error.message}`;
1494
+ if (task.status === 'running' && !task.managedCancelRequested) {
1495
+ task.managedCancelRequested = true;
1496
+ try {
1497
+ request.cancel();
1498
+ }
1499
+ catch (cancelError) {
1500
+ task.error = `${task.error}; cancellation failed: ${BackgroundTaskRegistry.errorMessage(cancelError)}`;
1501
+ }
1502
+ }
1503
+ });
1504
+ const unbindAdmissionCancellation = this.bindOwnedTaskToAdmission(task, admission);
1505
+ let completionAttached = false;
1506
+ const attachCompletion = () => {
1507
+ if (completionAttached)
1508
+ return;
1509
+ completionAttached = true;
1510
+ void request.completion
1511
+ .then(() => {
1512
+ const killed = task.killKind === 'user' || task.killKind === 'shutdown';
1513
+ const timedOut = task.killKind === 'timeout';
1514
+ return this.finalizeTask(task, killed ? 'killed' : timedOut ? 'failed' : 'completed', killed || timedOut ? null : 0, undefined, timedOut ? task.error : undefined);
1515
+ }, (error) => {
1516
+ const message = BackgroundTaskRegistry.errorMessage(error);
1517
+ const killed = task.killKind === 'user' || task.killKind === 'shutdown';
1518
+ return this.finalizeTask(task, killed ? 'killed' : 'failed', null, undefined, message);
1519
+ })
1520
+ .catch((error) => {
1521
+ this.logger.error(`[background-tasks] managed task finalization failed for ${task.id}:`, error);
1522
+ });
1523
+ };
1524
+ try {
1525
+ await this.awaitTaskAdmissionBoundary(this.writeMetadata(task, admission.controller.signal), admission);
1526
+ attachCompletion();
1527
+ this.assertTaskAdmissionOpen('a managed background task', admission);
1528
+ this.onChange();
1529
+ }
1530
+ catch (error) {
1531
+ if (admission.controller.signal.aborted) {
1532
+ attachCompletion();
1533
+ throw this.surfacedTaskAdmissionError(admission, error);
1534
+ }
1535
+ this.tasks.delete(task.id);
1536
+ await this.destroyTaskStream(task);
1537
+ try {
1538
+ request.cancel();
1539
+ }
1540
+ catch (cancelError) {
1541
+ this.logger.error(`[background-tasks] managed task cancellation after metadata failure also failed for ${task.id}:`, cancelError);
1542
+ }
1543
+ await request.completion.then(() => undefined, () => undefined);
1544
+ throw new Error(`Failed to register managed background task: ${BackgroundTaskRegistry.errorMessage(error)}`);
1545
+ }
1546
+ finally {
1547
+ unbindAdmissionCancellation();
1548
+ }
1549
+ return task;
1550
+ }
1551
+ async updateManagedTask(task, state, line) {
1552
+ if (task.status !== 'running' || task.fusion === undefined)
1553
+ return;
1554
+ task.fusion.state = state;
1555
+ if (line !== undefined && line.length > 0)
1556
+ this.writeNotice(task, `${line}\n`);
1557
+ await this.writeMetadata(task);
1558
+ this.onChange();
1559
+ }
1560
+ /** Claim deferred Fusion usage exactly once before returning it from bg_result. */
1561
+ async claimFusionUsage(task) {
1562
+ if (task.fusion === undefined)
1563
+ throw new Error(`Task ${task.id} is not a Fusion task`);
1564
+ let claimed = false;
1565
+ const write = async () => {
1566
+ if (!task.fusion || task.fusion.usageDelivered)
1567
+ return;
1568
+ task.fusion.usageDelivered = true;
1569
+ await writeJsonAtomic(task.metadataAbsPath, snapshot(task));
1570
+ claimed = true;
1571
+ };
1572
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1573
+ const next = previous.then(write, write);
1574
+ task.metadataWriteChain = next.catch(() => undefined);
1575
+ await next;
1576
+ return claimed;
1577
+ }
1578
+ /**
1579
+ * Start a prepared delegate child.
1580
+ *
1581
+ * The caller has already completed preflight, so by the time this runs the
1582
+ * seed, budget plan, and artifact directory exist and the argv is fixed. The
1583
+ * child is launched directly, never through a shell, and its terminal state
1584
+ * flows through the same durable notification path as `bg_run`.
1585
+ */
1586
+ async startDelegateTask(ctx, request) {
1587
+ rejectSurvivalForTaskKind(request, 'delegate tasks');
1588
+ const admission = this.beginTaskAdmission('a delegate task');
1589
+ try {
1590
+ return await this.startDelegateTaskAdmitted(ctx, request, admission);
1591
+ }
1592
+ finally {
1593
+ this.releaseTaskAdmission(admission);
1594
+ }
1595
+ }
1596
+ async startDelegateTaskAdmitted(ctx, request, admission) {
1597
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1598
+ const launch = resolvePiLaunch({ platform: this.platform });
1599
+ assertWindowsCommandLineWithinLimit(launch, request.argv, this.platform, 'bg-delegate');
1600
+ const dir = await this.awaitTaskAdmissionBoundary(this.ensureRuntimeDir(ctx), admission);
1601
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1602
+ const id = request.facts.taskId;
1603
+ const outputAbsPath = join(dir.abs, `${id}.output`);
1604
+ const metadataAbsPath = join(dir.abs, `${id}.json`);
1605
+ const outputPath = join(dir.display, `${id}.output`);
1606
+ const task = {
1607
+ id,
1608
+ name: normalizeTaskName(request.name) ?? 'Delegate task',
1609
+ command: ['pi', ...request.argv].map(shellQuote).join(' '),
1610
+ status: 'running',
1611
+ outputPath,
1612
+ outputAbsPath,
1613
+ metadataAbsPath,
1614
+ cwd: ctx.cwd,
1615
+ startTime: this.now(),
1616
+ exitCode: undefined,
1617
+ pid: undefined,
1618
+ bytesWritten: 0,
1619
+ isAgent: true,
1620
+ surviveReload: false,
1621
+ notified: false,
1622
+ notifyOnCompletion: request.notifyOnCompletion,
1623
+ triggerOnCompletion: request.triggerOnCompletion,
1624
+ timeoutSeconds: request.timeoutSeconds,
1625
+ model: request.facts.route.qualifiedId,
1626
+ delegate: request.facts,
1627
+ terminalPublished: false,
1628
+ terminalPublicationState: 'pending',
1629
+ terminalPublishAttempts: 0,
1630
+ waiters: [],
1631
+ };
1632
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1633
+ this.tasks.set(id, task);
1634
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1635
+ task.stream = stream;
1636
+ stream.on('error', (error) => {
1637
+ task.error = `Output file write failed: ${error.message}`;
1638
+ });
1639
+ let unbindAdmissionCancellation = () => undefined;
1640
+ try {
1641
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1642
+ const child = this.spawn(launch.executable, piLaunchArgv(launch, [...request.argv]), {
1643
+ cwd: ctx.cwd,
1644
+ detached: this.platform !== 'win32',
1645
+ shell: false,
1646
+ // The seed travels over stdin, never as a shell or positional argument,
1647
+ // so the bytes the child reads are exactly the bytes that were persisted
1648
+ // and hashed, with no quoting or command-line length limit in the path.
1649
+ stdio: ['pipe', 'pipe', 'pipe'],
1650
+ env: request.env,
1651
+ windowsHide: true,
1652
+ });
1653
+ this.captureSpawnedChild(task, child);
1654
+ child.stdout?.on('data', (data) => {
1655
+ this.appendChildOutput(task, data, 'stdout');
1656
+ });
1657
+ child.stderr?.on('data', (data) => {
1658
+ this.appendChildOutput(task, data, 'stderr');
1659
+ });
1660
+ child.on('error', (error) => {
1661
+ this.writeNotice(task, `\n[delegate spawn error: ${error.message}]\n`);
1662
+ void this.finalizeTask(task, 'failed', null, undefined, error.message);
1663
+ });
1664
+ child.on('close', (code, signalName) => {
1665
+ let status;
1666
+ let error;
1667
+ if (task.killKind === 'user' || task.killKind === 'shutdown') {
1668
+ status = 'killed';
1669
+ }
1670
+ else if (task.killKind === 'timeout') {
1671
+ status = 'failed';
1672
+ error = task.error ?? `Timed out after ${String(request.timeoutSeconds ?? 0)}s`;
1673
+ }
1674
+ else if ((code ?? 0) === 0) {
1675
+ status = 'completed';
1676
+ }
1677
+ else {
1678
+ status = 'failed';
1679
+ error = `Exited with code ${code === null ? 'null' : String(code)}${signalName ? ` (${signalName})` : ''}`;
1680
+ }
1681
+ void this.finalizeTask(task, status, code, signalName, error);
1682
+ });
1683
+ if (request.timeoutSeconds !== undefined) {
1684
+ task.timeoutHandle = setTimeout(() => {
1685
+ if (task.status !== 'running')
1686
+ return;
1687
+ task.killKind = 'timeout';
1688
+ task.error = `Timed out after ${String(request.timeoutSeconds)}s`;
1689
+ this.writeNotice(task, `\n[delegate timeout: ${task.error}]\n`);
1690
+ try {
1691
+ this.requestKill(task, 'SIGTERM');
1692
+ }
1693
+ catch (error) {
1694
+ void this.finalizeTask(task, 'failed', null, undefined, `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`);
1695
+ }
1696
+ }, request.timeoutSeconds * 1000);
1697
+ }
1698
+ unbindAdmissionCancellation = this.bindOwnedTaskToAdmission(task, admission);
1699
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1700
+ writeDelegateStdin(child, request.stdinBytes, (error) => {
1701
+ this.writeNotice(task, `\n[delegate stdin write failed: ${error.message}]\n`);
1702
+ if (task.status === 'running') {
1703
+ task.killKind = 'user';
1704
+ task.error = `Delegate seed could not be delivered: ${error.message}`;
1705
+ try {
1706
+ this.requestKill(task, 'SIGTERM');
1707
+ }
1708
+ catch {
1709
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
1710
+ }
1711
+ }
1712
+ });
1713
+ await this.awaitTaskAdmissionBoundary(this.writeMetadata(task, admission.controller.signal), admission);
1714
+ this.assertTaskAdmissionOpen('a delegate task', admission);
1715
+ this.onChange();
1716
+ return task;
1717
+ }
1718
+ catch (error) {
1719
+ if (admission.controller.signal.aborted) {
1720
+ const admissionError = this.taskAdmissionError(admission);
1721
+ let cleanupError;
1722
+ if (task.child === undefined) {
1723
+ try {
1724
+ await this.discardUnspawnedTask(task, [outputAbsPath, metadataAbsPath]);
1725
+ }
1726
+ catch (cleanupFailure) {
1727
+ cleanupError = cleanupFailure;
1728
+ }
1729
+ }
1730
+ else {
1731
+ this.stopOwnedTaskAfterAdmissionCancellation(task, admissionError);
1732
+ }
1733
+ throw this.surfacedTaskAdmissionError(admission, error, cleanupError);
1734
+ }
1735
+ const message = error instanceof Error ? error.message : String(error);
1736
+ this.writeNotice(task, `\n[delegate spawn exception: ${message}]\n`);
1737
+ await this.finalizeTask(task, 'failed', null, undefined, message);
1738
+ throw new Error(`Failed to start delegate task: ${message}`);
1739
+ }
1740
+ finally {
1741
+ unbindAdmissionCancellation();
1742
+ }
1743
+ }
1744
+ async startAttestedPiTask(ctx, request) {
1745
+ rejectSurvivalForTaskKind(request, 'attested Pi tasks');
1746
+ const admission = this.beginTaskAdmission('an attested Pi task');
1747
+ try {
1748
+ return await this.startAttestedPiTaskAdmitted(ctx, request, admission);
1749
+ }
1750
+ finally {
1751
+ this.releaseTaskAdmission(admission);
1752
+ }
1753
+ }
1754
+ async startAttestedPiTaskAdmitted(ctx, request, admission) {
1755
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1756
+ const attested = await this.awaitTaskAdmissionBoundary(this.loadAttestedRuntime(), admission);
1757
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1758
+ const attributionExtensionPath = request.provider === 'anthropic' ? resolveAnthropicAttributionExtensionPath() : undefined;
1759
+ const argv = attested.buildAttestedPiArgv(request, attributionExtensionPath);
1760
+ const attestedPiLaunch = resolvePiLaunch({ platform: this.platform });
1761
+ assertWindowsCommandLineWithinLimit(attestedPiLaunch, argv.slice(1), this.platform, 'attested-pi-run');
1762
+ const dir = await this.awaitTaskAdmissionBoundary(this.ensureRuntimeDir(ctx), admission);
1763
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1764
+ const id = attested.makeAttestedTaskId();
1765
+ if (!ATTESTED_TASK_ID_PATTERN.test(id))
1766
+ throw new Error('Generated attested task id is invalid');
1767
+ const paths = attested.makeAttestedTaskPaths(dir.abs, dir.display, id);
1768
+ const promptBytes = Buffer.from(request.prompt, 'utf8');
1769
+ const reportAbsPath = await this.awaitTaskAdmissionBoundary(attested.resolveReportPath(ctx.cwd, request.reportPath), admission);
1770
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1771
+ const auth = attested.observePiOAuth(ctx, request.provider, request.model);
1772
+ const gitOptions = this.attestedGitOptions(admission);
1773
+ const repoRootRealpath = await attested.gitRepoRoot(ctx.cwd, gitOptions);
1774
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1775
+ const cwdRealpath = await this.awaitTaskAdmissionBoundary(realpath(ctx.cwd), admission);
1776
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1777
+ const startAuthority = await attested.gitAuthoritySnapshot(ctx.cwd, gitOptions);
1778
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1779
+ if (!startAuthority.clean)
1780
+ throw new Error('Attested Pi task requires a clean worktree at start');
1781
+ const timeoutSeconds = typeof request.timeoutSeconds === 'number' &&
1782
+ Number.isFinite(request.timeoutSeconds) &&
1783
+ request.timeoutSeconds > 0
1784
+ ? Math.floor(request.timeoutSeconds)
1785
+ : undefined;
1786
+ const task = {
1787
+ id,
1788
+ name: normalizeTaskName(request.name) ?? 'Attested Pi task',
1789
+ command: argv.map(shellQuote).join(' '),
1790
+ status: 'running',
1791
+ outputPath: paths.outputPath,
1792
+ outputAbsPath: paths.outputAbsPath,
1793
+ metadataAbsPath: paths.metadataAbsPath,
1794
+ eventsAbsPath: paths.eventsAbsPath,
1795
+ stderrAbsPath: paths.stderrAbsPath,
1796
+ wrapperAbsPath: paths.wrapperAbsPath,
1797
+ attestationAbsPath: paths.attestationAbsPath,
1798
+ cwd: ctx.cwd,
1799
+ startTime: this.now(),
1800
+ exitCode: undefined,
1801
+ pid: undefined,
1802
+ bytesWritten: 0,
1803
+ isAgent: true,
1804
+ surviveReload: false,
1805
+ notified: false,
1806
+ notifyOnCompletion: false,
1807
+ triggerOnCompletion: false,
1808
+ timeoutSeconds,
1809
+ attestationPath: paths.attestationPath,
1810
+ attestedPi: {
1811
+ eventsPath: paths.eventsPath,
1812
+ stderrPath: paths.stderrPath,
1813
+ wrapperPath: paths.wrapperPath,
1814
+ attestationPath: paths.attestationPath,
1815
+ },
1816
+ terminalPublished: false,
1817
+ terminalPublicationState: 'pending',
1818
+ terminalPublishAttempts: 0,
1819
+ waiters: [],
1820
+ };
1821
+ const admissionArtifacts = [
1822
+ paths.outputAbsPath,
1823
+ paths.eventsAbsPath,
1824
+ paths.stderrAbsPath,
1825
+ paths.wrapperAbsPath,
1826
+ paths.metadataAbsPath,
1827
+ paths.attestationAbsPath,
1828
+ ];
1829
+ const admissionSignal = admission.controller.signal;
1830
+ try {
1831
+ await writeFileFsynced(paths.outputAbsPath, '', admissionSignal);
1832
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1833
+ await writeFileFsynced(paths.eventsAbsPath, '', admissionSignal);
1834
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1835
+ await writeFileFsynced(paths.stderrAbsPath, '', admissionSignal);
1836
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1837
+ await writeFileFsynced(paths.wrapperAbsPath, 'direct-spawn attested Pi task; no shell telemetry wrapper is used\n', admissionSignal);
1838
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1839
+ await this.writeMetadata(task, admissionSignal);
1840
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1841
+ }
1842
+ catch (error) {
1843
+ let cleanupError;
1844
+ try {
1845
+ await this.discardUnspawnedTask(task, admissionArtifacts);
1846
+ }
1847
+ catch (cleanupFailure) {
1848
+ cleanupError = cleanupFailure;
1849
+ }
1850
+ if (admissionSignal.aborted) {
1851
+ throw this.surfacedTaskAdmissionError(admission, error, cleanupError);
1852
+ }
1853
+ if (cleanupError !== undefined) {
1854
+ throw new AggregateError([error, cleanupError], `Attested Pi preflight failed and artifact cleanup also failed: ${BackgroundTaskRegistry.errorMessage(cleanupError)}`);
1855
+ }
1856
+ throw error;
1857
+ }
1858
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1859
+ this.tasks.set(id, task);
1860
+ let unbindAdmissionCancellation = () => undefined;
1861
+ try {
1862
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1863
+ const captured = attested.spawnAndCapturePi(this.spawn, argv, {
1864
+ cwd: ctx.cwd,
1865
+ detached: this.platform !== 'win32',
1866
+ shell: false,
1867
+ stdio: ['ignore', 'pipe', 'pipe'],
1868
+ env: attested.attestedPiChildEnv(this.env),
1869
+ windowsHide: true,
1870
+ }, this.platform, attestedPiLaunch);
1871
+ this.captureSpawnedChild(task, captured.child);
1872
+ captured.child.on('error', (error) => {
1873
+ void this.finalizeAttestedPiTask(task, attested, paths, argv, cwdRealpath, repoRootRealpath, startAuthority, auth, promptBytes, reportAbsPath, captured.stdoutChunks, captured.stderrChunks, 'failed', null, null, error.message);
1874
+ });
1875
+ captured.child.on('close', (code, signalName) => {
1876
+ let status = (code ?? 0) === 0 && signalName === null ? 'completed' : 'failed';
1877
+ let error;
1878
+ if (task.killKind === 'timeout') {
1879
+ status = 'failed';
1880
+ error = task.error ?? `Timed out after ${String(timeoutSeconds)}s`;
1881
+ }
1882
+ else if (task.killKind === 'user' || task.killKind === 'shutdown') {
1883
+ status = 'killed';
1884
+ error = task.error;
1885
+ }
1886
+ else if (status === 'failed') {
1887
+ const exitCode = code === null ? 'null' : String(code);
1888
+ error = `Exited with code ${exitCode}${signalName ? ` (${signalName})` : ''}`;
1889
+ }
1890
+ void this.finalizeAttestedPiTask(task, attested, paths, argv, cwdRealpath, repoRootRealpath, startAuthority, auth, promptBytes, reportAbsPath, captured.stdoutChunks, captured.stderrChunks, status, code, signalName, error);
1891
+ });
1892
+ unbindAdmissionCancellation = this.bindOwnedTaskToAdmission(task, admission);
1893
+ await this.awaitTaskAdmissionBoundary(this.writeMetadata(task, admissionSignal), admission);
1894
+ this.assertTaskAdmissionOpen('an attested Pi task', admission);
1895
+ this.onChange();
1896
+ if (timeoutSeconds !== undefined) {
1897
+ task.timeoutHandle = setTimeout(() => {
1898
+ if (task.status !== 'running')
1899
+ return;
1900
+ task.killKind = 'timeout';
1901
+ task.error = `Timed out after ${String(timeoutSeconds)}s`;
1902
+ try {
1903
+ this.requestKill(task, 'SIGTERM');
1904
+ }
1905
+ catch (error) {
1906
+ void this.finalizeAttestedPiTask(task, attested, paths, argv, cwdRealpath, repoRootRealpath, startAuthority, auth, promptBytes, reportAbsPath, captured.stdoutChunks, captured.stderrChunks, 'failed', null, null, error instanceof Error ? error.message : String(error));
1907
+ }
1908
+ }, timeoutSeconds * 1000);
1909
+ }
1910
+ return task;
1911
+ }
1912
+ catch (error) {
1913
+ let cleanupError;
1914
+ if (task.child === undefined) {
1915
+ try {
1916
+ await this.discardUnspawnedTask(task, admissionArtifacts);
1917
+ }
1918
+ catch (cleanupFailure) {
1919
+ cleanupError = cleanupFailure;
1920
+ }
1921
+ }
1922
+ else {
1923
+ const taskError = admissionSignal.aborted
1924
+ ? this.taskAdmissionError(admission)
1925
+ : error instanceof Error
1926
+ ? error
1927
+ : new Error(String(error));
1928
+ this.stopOwnedTaskAfterAdmissionCancellation(task, taskError);
1929
+ }
1930
+ if (admissionSignal.aborted) {
1931
+ throw this.surfacedTaskAdmissionError(admission, error, cleanupError);
1932
+ }
1933
+ if (cleanupError !== undefined) {
1934
+ throw new AggregateError([error, cleanupError], `Attested Pi launch failed and artifact cleanup also failed: ${BackgroundTaskRegistry.errorMessage(cleanupError)}`);
1935
+ }
1936
+ throw error;
1937
+ }
1938
+ finally {
1939
+ unbindAdmissionCancellation();
1940
+ }
1941
+ }
1942
+ async finalizeAttestedPiTask(task, attested, paths, argv, cwdRealpath, repoRootRealpath, startAuthority, auth, promptBytes, reportAbsPath, stdoutChunks, stderrChunks, status, exitCode, signal, error) {
1943
+ if (task.finalized)
1944
+ return;
1945
+ task.finalized = true;
1946
+ if (task.timeoutHandle)
1947
+ clearTimeout(task.timeoutHandle);
1948
+ if (this.platform === 'win32')
1949
+ this.clearKillEscalationTimer(task);
1950
+ let finalStatus = status;
1951
+ let finalError = error;
1952
+ const posixForceFailure = await this.awaitPosixProcessGroupBeforeTerminal(task);
1953
+ if (posixForceFailure !== undefined) {
1954
+ finalStatus = 'failed';
1955
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, posixForceFailure.message);
1956
+ }
1957
+ const windowsForceFailure = await this.awaitWindowsForceBeforeTerminal(task);
1958
+ if (windowsForceFailure !== undefined) {
1959
+ finalStatus = 'failed';
1960
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, windowsForceFailure.message);
1961
+ }
1962
+ task.exitCode = exitCode;
1963
+ task.signal = signal;
1964
+ task.endTime = this.now();
1965
+ if (finalError)
1966
+ task.error = finalError;
1967
+ const rawEvents = Buffer.concat(stdoutChunks);
1968
+ const rawStderr = Buffer.concat(stderrChunks);
1969
+ await writeFileFsynced(paths.eventsAbsPath, rawEvents);
1970
+ await writeFileFsynced(paths.stderrAbsPath, rawStderr);
1971
+ let parsed;
1972
+ if (finalStatus === 'completed') {
1973
+ try {
1974
+ parsed = attested.parsePiJsonEvents(rawEvents);
1975
+ task.model = parsed.providerScopedModelId;
1976
+ task.tokenUsage = {
1977
+ input: parsed.tokenUsage.input,
1978
+ output: parsed.tokenUsage.output,
1979
+ cacheRead: parsed.tokenUsage.cacheRead,
1980
+ cacheWrite: parsed.tokenUsage.cacheWrite,
1981
+ totalTokens: parsed.tokenUsage.totalTokens,
1982
+ };
1983
+ if (parsed.tokenUsage.costTotal !== undefined)
1984
+ task.tokenUsage.costTotal = parsed.tokenUsage.costTotal;
1985
+ task.toolUsage = parsed.toolUsage;
1986
+ const outputBytes = Buffer.from(parsed.humanTranscript, 'utf8');
1987
+ task.bytesWritten = outputBytes.length;
1988
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1989
+ }
1990
+ catch (parseError) {
1991
+ finalStatus = 'failed';
1992
+ task.error = parseError instanceof Error ? parseError.message : String(parseError);
1993
+ const outputBytes = Buffer.from(`[attested Pi task error: ${task.error}]\n`, 'utf8');
1994
+ task.bytesWritten = outputBytes.length;
1995
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
1996
+ }
1997
+ }
1998
+ else {
1999
+ const outputBytes = Buffer.from(rawStderr.toString('utf8'), 'utf8');
2000
+ task.bytesWritten = outputBytes.length;
2001
+ await writeFileFsynced(paths.outputAbsPath, outputBytes);
2002
+ }
2003
+ try {
2004
+ if (finalStatus === 'completed' && parsed) {
2005
+ const finishAuthority = await attested.gitAuthoritySnapshot(task.cwd, this.attestedGitOptions());
2006
+ const completedSnapshot = { ...snapshot(task), status: 'completed' };
2007
+ await this.writeMetadataSnapshot(task, completedSnapshot);
2008
+ const attestation = await attested.buildPiTaskAttestation({
2009
+ task: completedSnapshot,
2010
+ paths,
2011
+ sessionDir: dirNameFromDisplay(paths.outputPath),
2012
+ argv,
2013
+ cwdRealpath,
2014
+ repoRootRealpath,
2015
+ startAuthority,
2016
+ finishAuthority,
2017
+ parsedEvents: parsed,
2018
+ auth,
2019
+ prompt: promptBytes,
2020
+ reportAbsPath,
2021
+ });
2022
+ await writeJsonAtomic(paths.attestationAbsPath, attestation);
2023
+ }
2024
+ else {
2025
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
2026
+ }
2027
+ }
2028
+ catch (attestationError) {
2029
+ finalStatus = 'failed';
2030
+ task.error =
2031
+ attestationError instanceof Error ? attestationError.message : String(attestationError);
2032
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: 'failed' }).catch((metadataError) => {
2033
+ this.logger.error(`[background-tasks] failed to write failed attested metadata for ${task.id}:`, metadataError);
2034
+ });
2035
+ }
2036
+ task.status = finalStatus;
2037
+ for (const waiter of task.waiters.splice(0))
2038
+ waiter();
2039
+ this.onChange();
2040
+ this.publishTerminal(task);
2041
+ this.pruneOldTasks();
2042
+ }
2043
+ resolveTask(idOrPrefix) {
2044
+ const id = idOrPrefix.trim();
2045
+ if (!id)
2046
+ throw new Error('Task ID is required');
2047
+ const exact = this.tasks.get(id);
2048
+ if (exact)
2049
+ return exact;
2050
+ const matches = [...this.tasks.values()].filter((task) => task.id.startsWith(id));
2051
+ const onlyMatch = matches[0];
2052
+ if (matches.length === 1 && onlyMatch)
2053
+ return onlyMatch;
2054
+ if (matches.length > 1)
2055
+ throw new Error(`Ambiguous task ID prefix "${id}": ${matches.map((task) => task.id).join(', ')}`);
2056
+ throw new Error(`Unknown background task ID: ${id}`);
2057
+ }
2058
+ async stopTask(task, kind, reason) {
2059
+ if (task.status !== 'running') {
2060
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
2061
+ }
2062
+ if (task.reloadExecution !== undefined) {
2063
+ return task.reloadExecution.requestStop(kind, reason);
2064
+ }
2065
+ const stopWaitMs = task.managedStopWaitMs ?? this.stopWaitMs;
2066
+ if (this.platform !== 'win32' &&
2067
+ task.managedCancel === undefined &&
2068
+ task.posixProcessGroupSignalAuthorityReleased === true &&
2069
+ this.posixProcessGroupKillStates.get(task) === undefined) {
2070
+ const finalized = await this.waitForEnd(task, stopWaitMs);
2071
+ if (!finalized) {
2072
+ throw new Error(`Task ${task.id} did not finish terminalization within ${formatDuration(stopWaitMs)} after its process group signal authority was released`);
2073
+ }
2074
+ return task;
2075
+ }
2076
+ task.killKind = kind;
2077
+ if (reason)
2078
+ task.error = reason;
2079
+ this.requestKill(task, 'SIGTERM');
2080
+ const stopped = task.managedCancel === undefined && this.platform === 'win32'
2081
+ ? await this.waitForEndOrWindowsForceFailure(task, stopWaitMs)
2082
+ : task.managedCancel === undefined
2083
+ ? await this.waitForEndOrPosixForceFailure(task, stopWaitMs)
2084
+ : await this.waitForEnd(task, stopWaitMs);
2085
+ const posixForceFailure = this.posixProcessGroupKillStates.get(task)?.failure;
2086
+ if (posixForceFailure !== undefined)
2087
+ throw posixForceFailure;
2088
+ const windowsForceFailure = this.windowsKillStates.get(task)?.forceFailure;
2089
+ if (windowsForceFailure !== undefined)
2090
+ throw windowsForceFailure;
2091
+ if (!stopped) {
2092
+ throw new Error(`Task ${task.id} did not exit within ${formatDuration(stopWaitMs)} after cancellation`);
2093
+ }
2094
+ return task;
2095
+ }
2096
+ async stopAllRunning(kind, reason) {
2097
+ const running = this.allTasks().filter((task) => task.status === 'running');
2098
+ const failures = [];
2099
+ let stopped = 0;
2100
+ await Promise.all(running.map(async (task) => {
2101
+ try {
2102
+ await this.stopTask(task, kind, reason);
2103
+ stopped++;
2104
+ }
2105
+ catch (error) {
2106
+ failures.push(`${taskDisplayName(task)} (${task.id}): ${error instanceof Error ? error.message : String(error)}`);
2107
+ }
2108
+ }));
2109
+ return { stopped, failures };
2110
+ }
2111
+ async getTaskLogs(task, maxBytes, tail) {
2112
+ if (!existsSync(task.outputAbsPath)) {
2113
+ throw new Error(`Output file does not exist for ${task.id}: ${task.outputPath}`);
2114
+ }
2115
+ const read = await boundedRead(task.outputAbsPath, maxBytes, tail);
2116
+ const direction = tail ? 'tail' : 'head';
2117
+ let text = read.content.length > 0 ? read.content : '(no output yet)';
2118
+ if (read.truncated) {
2119
+ const omitted = read.totalBytes - read.bytesRead;
2120
+ const notice = `\n\n[Showing ${direction} ${formatSize(read.bytesRead)} of ${formatSize(read.totalBytes)}; ${formatSize(omitted)} omitted. Full output: ${task.outputPath}]`;
2121
+ text = tail ? `${notice}\n\n${text}` : `${text}${notice}`;
2122
+ }
2123
+ else {
2124
+ text += `\n\n[Full output: ${task.outputPath}]`;
2125
+ }
2126
+ return {
2127
+ text,
2128
+ details: {
2129
+ task: snapshot(task),
2130
+ path: task.outputPath,
2131
+ bytesRead: read.bytesRead,
2132
+ truncated: read.truncated,
2133
+ tail,
2134
+ },
2135
+ };
2136
+ }
2137
+ async writeMetadata(task, signal) {
2138
+ await this.writeMetadataSnapshot(task, snapshot(task), signal);
2139
+ }
2140
+ async writeMetadataSnapshot(task, value, signal) {
2141
+ const write = async () => {
2142
+ await writeJsonAtomic(task.metadataAbsPath, value, signal);
2143
+ };
2144
+ const previous = task.metadataWriteChain ?? Promise.resolve();
2145
+ const next = previous.then(write, write);
2146
+ task.metadataWriteChain = next.catch(() => undefined);
2147
+ await next;
2148
+ }
2149
+ ingestTelemetry(task, text) {
2150
+ if (!text)
2151
+ return;
2152
+ const telemetryText = `${task.contextUsageBuffer ?? ''}${text}`;
2153
+ let latestContext = task.contextUsage;
2154
+ let latestTokens = task.tokenUsage;
2155
+ let latestTools = task.toolUsage;
2156
+ let latestModel = task.model;
2157
+ for (const line of telemetryText.split(/\r?\n/)) {
2158
+ if (!line.includes('background-task-'))
2159
+ continue;
2160
+ const trimmed = line.trim();
2161
+ if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
2162
+ try {
2163
+ const parsed = parseJsonText(trimmed);
2164
+ if (!isJsonObject(parsed))
2165
+ continue;
2166
+ const payload = parsed;
2167
+ if (payload.type === 'background-task-context-usage') {
2168
+ latestContext = normalizeContextUsage(payload) ?? latestContext;
2169
+ }
2170
+ else if (payload.type === 'background-task-telemetry') {
2171
+ latestContext = normalizeContextUsage(payload.contextUsage) ?? latestContext;
2172
+ latestTokens = normalizeTokenUsage(payload.tokenUsage) ?? latestTokens;
2173
+ latestTools = normalizeToolUsage(payload.toolUsage) ?? latestTools;
2174
+ latestModel = normalizeModel(payload.model) ?? latestModel;
2175
+ }
2176
+ }
2177
+ catch {
2178
+ // Ignore malformed optional telemetry; task output remains authoritative for debugging.
2179
+ }
2180
+ }
2181
+ }
2182
+ const xmlMatches = telemetryText.matchAll(/<background-task-context-usage>[\s\S]*?<\/background-task-context-usage>/gi);
2183
+ for (const match of xmlMatches)
2184
+ latestContext = parseContextUsageXml(match[0]) ?? latestContext;
2185
+ const lastNewline = Math.max(telemetryText.lastIndexOf('\n'), telemetryText.lastIndexOf('\r'));
2186
+ let retained = lastNewline >= 0 ? telemetryText.slice(lastNewline + 1) : telemetryText;
2187
+ const lastXmlOpen = telemetryText.toLowerCase().lastIndexOf('<background-task-context-usage');
2188
+ const lastXmlClose = telemetryText
2189
+ .toLowerCase()
2190
+ .lastIndexOf('</background-task-context-usage>');
2191
+ if (lastXmlOpen > lastXmlClose)
2192
+ retained = telemetryText.slice(lastXmlOpen);
2193
+ task.contextUsageBuffer = retained.slice(-TELEMETRY_BUFFER_CHARS);
2194
+ this.commitTelemetry(task, {
2195
+ context: latestContext,
2196
+ tokens: latestTokens,
2197
+ tools: latestTools,
2198
+ model: latestModel,
2199
+ });
2200
+ }
2201
+ /** Apply the latest parsed telemetry to a task, persisting metadata and notifying the UI only on change. */
2202
+ commitTelemetry(task, next) {
2203
+ const before = JSON.stringify({
2204
+ contextUsage: task.contextUsage,
2205
+ tokenUsage: task.tokenUsage,
2206
+ toolUsage: task.toolUsage,
2207
+ model: task.model,
2208
+ });
2209
+ if (next.context !== undefined)
2210
+ task.contextUsage = next.context;
2211
+ if (next.tokens !== undefined)
2212
+ task.tokenUsage = next.tokens;
2213
+ if (next.tools !== undefined)
2214
+ task.toolUsage = next.tools;
2215
+ if (next.model !== undefined)
2216
+ task.model = next.model;
2217
+ const after = JSON.stringify({
2218
+ contextUsage: task.contextUsage,
2219
+ tokenUsage: task.tokenUsage,
2220
+ toolUsage: task.toolUsage,
2221
+ model: task.model,
2222
+ });
2223
+ if (before !== after) {
2224
+ this.onChange();
2225
+ void this.writeMetadata(task).catch((error) => {
2226
+ this.logger.error(`[background-tasks] failed to write telemetry metadata for ${task.id}:`, error);
2227
+ });
2228
+ }
2229
+ }
2230
+ /** Cap-enforcing sink for all persisted task output; terminates the task once the byte cap is exceeded. */
2231
+ writeToStream(task, buffer) {
2232
+ if (!task.stream || task.stream.destroyed)
2233
+ return;
2234
+ if (buffer.length === 0)
2235
+ return;
2236
+ const nextBytes = task.bytesWritten + buffer.length;
2237
+ if (nextBytes <= this.maxOutputBytes) {
2238
+ task.stream.write(buffer);
2239
+ task.bytesWritten = nextBytes;
2240
+ return;
2241
+ }
2242
+ const remaining = Math.max(0, this.maxOutputBytes - task.bytesWritten);
2243
+ if (remaining > 0) {
2244
+ task.stream.write(buffer.subarray(0, remaining));
2245
+ task.bytesWritten += remaining;
2246
+ }
2247
+ if (!task.capExceeded) {
2248
+ task.capExceeded = true;
2249
+ task.error = `Output exceeded cap of ${formatSize(this.maxOutputBytes)}; terminating task`;
2250
+ const notice = `\n\n[background task error: ${task.error}]\n`;
2251
+ task.stream.write(notice);
2252
+ task.bytesWritten += Buffer.byteLength(notice, 'utf8');
2253
+ task.killKind = 'output_cap';
2254
+ try {
2255
+ this.requestKill(task, 'SIGTERM');
2256
+ }
2257
+ catch (error) {
2258
+ task.error = `${task.error}; kill failed: ${error instanceof Error ? error.message : String(error)}`;
2259
+ void this.finalizeTask(task, 'failed', null, undefined, task.error);
2260
+ }
2261
+ }
2262
+ }
2263
+ /** Persist an internally generated notice (spawn/timeout/cap diagnostics) verbatim. */
2264
+ writeNotice(task, text) {
2265
+ if (!text)
2266
+ return;
2267
+ this.writeToStream(task, Buffer.from(text, 'utf8'));
2268
+ }
2269
+ appendChildOutput(task, data, source) {
2270
+ if (!task.stream || task.stream.destroyed)
2271
+ return;
2272
+ const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
2273
+ if (buffer.length === 0)
2274
+ return;
2275
+ if (task.telemetryWrapped) {
2276
+ // Wrapped Pi agents stream control lines on stdout (telemetry + activity); child
2277
+ // stderr is raw diagnostics and is always passed through to the transcript verbatim.
2278
+ if (source === 'stdout')
2279
+ this.processAgentStdout(task, buffer.toString('utf8'));
2280
+ else
2281
+ this.writeToStream(task, buffer);
2282
+ return;
2283
+ }
2284
+ this.ingestTelemetry(task, buffer.toString('utf8'));
2285
+ this.writeToStream(task, buffer);
2286
+ }
2287
+ /** Reconstruct wrapped-agent stdout into whole control lines, routing telemetry to metrics and activity to the transcript. */
2288
+ processAgentStdout(task, text) {
2289
+ const buffered = `${task.agentStdoutBuffer ?? ''}${text}`;
2290
+ const lastNewline = buffered.lastIndexOf('\n');
2291
+ task.agentStdoutBuffer = lastNewline >= 0 ? buffered.slice(lastNewline + 1) : buffered;
2292
+ if (lastNewline < 0)
2293
+ return;
2294
+ const latest = {};
2295
+ for (const line of buffered.slice(0, lastNewline).split('\n'))
2296
+ this.consumeAgentLine(task, line, latest);
2297
+ this.commitTelemetry(task, latest);
2298
+ }
2299
+ /** Flush a trailing partial wrapped-agent line on finalize so the last transcript fragment is never lost. */
2300
+ flushAgentStdout(task) {
2301
+ const remainder = task.agentStdoutBuffer;
2302
+ if (!remainder)
2303
+ return;
2304
+ task.agentStdoutBuffer = '';
2305
+ const latest = {};
2306
+ this.consumeAgentLine(task, remainder, latest);
2307
+ this.commitTelemetry(task, latest);
2308
+ }
2309
+ consumeAgentLine(task, rawLine, latest) {
2310
+ const line = rawLine.replace(/\r$/, '');
2311
+ const trimmed = line.trim();
2312
+ if (!trimmed)
2313
+ return;
2314
+ if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
2315
+ this.writeNotice(task, `${line}\n`);
2316
+ return;
2317
+ }
2318
+ let parsed;
2319
+ try {
2320
+ parsed = parseJsonText(trimmed);
2321
+ }
2322
+ catch {
2323
+ this.writeNotice(task, `${line}\n`);
2324
+ return;
2325
+ }
2326
+ if (!isJsonObject(parsed)) {
2327
+ this.writeNotice(task, `${line}\n`);
2328
+ return;
2329
+ }
2330
+ const record = parsed;
2331
+ const type = record.type;
2332
+ if (type === 'background-task-context-usage') {
2333
+ const context = normalizeContextUsage(record);
2334
+ if (context)
2335
+ latest.context = context;
2336
+ return;
2337
+ }
2338
+ if (type === 'background-task-telemetry') {
2339
+ const context = normalizeContextUsage(record.contextUsage);
2340
+ if (context)
2341
+ latest.context = context;
2342
+ const tokens = normalizeTokenUsage(record.tokenUsage);
2343
+ if (tokens)
2344
+ latest.tokens = tokens;
2345
+ const tools = normalizeToolUsage(record.toolUsage);
2346
+ if (tools)
2347
+ latest.tools = tools;
2348
+ const model = normalizeModel(record.model);
2349
+ if (model)
2350
+ latest.model = model;
2351
+ return;
2352
+ }
2353
+ const activity = parseAgentActivity(parsed);
2354
+ if (activity) {
2355
+ const formatted = formatAgentActivityLine(activity);
2356
+ if (formatted)
2357
+ this.writeNotice(task, `${formatted}\n`);
2358
+ return;
2359
+ }
2360
+ // Unknown JSON object: pass through to the transcript rather than silently dropping it.
2361
+ this.writeNotice(task, `${line}\n`);
2362
+ }
2363
+ beginPosixProcessGroupKill(task, armGrace) {
2364
+ const existing = this.posixProcessGroupKillStates.get(task);
2365
+ if (existing !== undefined)
2366
+ return existing;
2367
+ if (task.posixProcessGroupSignalAuthorityReleased === true) {
2368
+ throw new Error(`Task ${task.id} has released its POSIX process-group signal authority`);
2369
+ }
2370
+ const groupId = task.ownedPosixProcessGroupId;
2371
+ if (groupId === undefined) {
2372
+ throw new Error(`Task ${task.id} has no owned POSIX process group`);
2373
+ }
2374
+ let resolveCompletion = () => undefined;
2375
+ const completion = new Promise((resolve) => {
2376
+ resolveCompletion = resolve;
2377
+ });
2378
+ // Finish ownership slightly before stopTask's waiter so a force/proof
2379
+ // failure is observed as that specific loud error instead of a generic
2380
+ // cancellation timeout.
2381
+ const reserveMs = Math.min(25, Math.max(1, Math.floor(this.stopWaitMs / 4)));
2382
+ const ownershipMs = Math.max(1, this.stopWaitMs - reserveMs);
2383
+ const state = {
2384
+ groupId,
2385
+ completion,
2386
+ resolveCompletion,
2387
+ deadlineAt: Date.now() + ownershipMs,
2388
+ forceAttempted: false,
2389
+ settled: false,
2390
+ };
2391
+ this.posixProcessGroupKillStates.set(task, state);
2392
+ if (armGrace) {
2393
+ // Publish the sole force owner before TERM. An injected signal can emit
2394
+ // close reentrantly; that close must see and await this exact state.
2395
+ task.killEscalationTimer = setTimeout(() => {
2396
+ task.killEscalationTimer = undefined;
2397
+ this.forceOwnedPosixProcessGroup(task, state);
2398
+ }, Math.min(this.killGraceMs, ownershipMs));
2399
+ // Unlike ordinary housekeeping timers, this owner stays referenced: a
2400
+ // departed leader must not let the host exit and strand its owned group.
2401
+ }
2402
+ return state;
2403
+ }
2404
+ finishPosixProcessGroupKill(task, state, releaseOwnership) {
2405
+ if (state.settled)
2406
+ return;
2407
+ state.settled = true;
2408
+ this.clearKillEscalationTimer(task);
2409
+ if (state.verificationTimer !== undefined) {
2410
+ clearTimeout(state.verificationTimer);
2411
+ state.verificationTimer = undefined;
2412
+ }
2413
+ task.posixProcessGroupSignalAuthorityReleased = true;
2414
+ if (releaseOwnership && task.ownedPosixProcessGroupId === state.groupId) {
2415
+ delete task.ownedPosixProcessGroupId;
2416
+ }
2417
+ state.resolveCompletion();
2418
+ const failure = state.failure;
2419
+ const listeners = state.failureListeners;
2420
+ if (listeners !== undefined) {
2421
+ delete state.failureListeners;
2422
+ if (failure !== undefined) {
2423
+ for (const listener of listeners)
2424
+ listener(failure);
2425
+ }
2426
+ }
2427
+ }
2428
+ observeOwnedPosixProcessGroupGone(task, state) {
2429
+ if (state.settled)
2430
+ return state.failure === undefined;
2431
+ try {
2432
+ const exists = this.killProcess(-state.groupId, 0);
2433
+ state.lastProbeError = exists
2434
+ ? undefined
2435
+ : new Error(`process-group probe for ${String(state.groupId)} returned false`);
2436
+ return false;
2437
+ }
2438
+ catch (error) {
2439
+ if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ESRCH') {
2440
+ this.finishPosixProcessGroupKill(task, state, true);
2441
+ return true;
2442
+ }
2443
+ state.lastProbeError =
2444
+ error instanceof Error ? error : new Error(BackgroundTaskRegistry.errorMessage(error));
2445
+ return false;
2446
+ }
2447
+ }
2448
+ recordPosixProcessGroupForceFailure(task, state, error) {
2449
+ if (state.settled)
2450
+ return;
2451
+ state.failure = error;
2452
+ this.finishPosixProcessGroupKill(task, state, false);
2453
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, error.message);
2454
+ this.writeNotice(task, `\n[background task POSIX termination: ${error.message}]\n`);
2455
+ this.onChange();
2456
+ void this.writeMetadata(task).catch((metadataError) => {
2457
+ this.logger.error(`[background-tasks] failed to write POSIX process-group failure metadata for ${task.id}:`, metadataError);
2458
+ });
2459
+ }
2460
+ schedulePosixProcessGroupVerification(task, state) {
2461
+ if (state.settled)
2462
+ return;
2463
+ const remainingMs = state.deadlineAt - Date.now();
2464
+ if (remainingMs <= 0) {
2465
+ const probeDetail = state.lastProbeError === undefined
2466
+ ? ''
2467
+ : `; last group probe failed: ${state.lastProbeError.message}`;
2468
+ this.recordPosixProcessGroupForceFailure(task, state, new Error(`POSIX process group ${String(state.groupId)} remained present after SIGKILL${probeDetail}. Descendant processes may have leaked.`));
2469
+ return;
2470
+ }
2471
+ state.verificationTimer = setTimeout(() => {
2472
+ state.verificationTimer = undefined;
2473
+ if (this.observeOwnedPosixProcessGroupGone(task, state))
2474
+ return;
2475
+ this.schedulePosixProcessGroupVerification(task, state);
2476
+ }, Math.min(10, remainingMs));
2477
+ }
2478
+ forceOwnedPosixProcessGroup(task, state) {
2479
+ if (state.settled || state.forceAttempted)
2480
+ return;
2481
+ // Latch before either probe or signal: both are injected boundaries that can
2482
+ // reentrantly emit root close, and no continuation may launch a second KILL.
2483
+ state.forceAttempted = true;
2484
+ this.clearKillEscalationTimer(task);
2485
+ if (this.observeOwnedPosixProcessGroupGone(task, state))
2486
+ return;
2487
+ try {
2488
+ const forced = this.killProcess(-state.groupId, 'SIGKILL');
2489
+ if (!forced) {
2490
+ this.recordPosixProcessGroupForceFailure(task, state, new Error(`POSIX process-group SIGKILL returned false for task ${task.id} group ${String(state.groupId)}. Descendant processes may have leaked.`));
2491
+ return;
2492
+ }
2493
+ }
2494
+ catch (error) {
2495
+ if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ESRCH') {
2496
+ this.finishPosixProcessGroupKill(task, state, true);
2497
+ return;
2498
+ }
2499
+ this.recordPosixProcessGroupForceFailure(task, state, new Error(`POSIX process-group SIGKILL failed for task ${task.id} group ${String(state.groupId)}: ${BackgroundTaskRegistry.errorMessage(error)}. Descendant processes may have leaked.`));
2500
+ return;
2501
+ }
2502
+ if (this.observeOwnedPosixProcessGroupGone(task, state))
2503
+ return;
2504
+ this.schedulePosixProcessGroupVerification(task, state);
2505
+ }
2506
+ requestPosixKill(task, signal) {
2507
+ const state = this.beginPosixProcessGroupKill(task, signal !== 'SIGKILL');
2508
+ if (signal === 'SIGKILL') {
2509
+ task.killSignalSent = true;
2510
+ this.forceOwnedPosixProcessGroup(task, state);
2511
+ return;
2512
+ }
2513
+ if (task.killSignalSent)
2514
+ return;
2515
+ // Publish de-duplication before the signal boundary for the same reason the
2516
+ // state/timer is published above: close may be emitted synchronously.
2517
+ task.killSignalSent = true;
2518
+ const errors = [];
2519
+ let killed = false;
2520
+ try {
2521
+ killed = this.killProcess(-state.groupId, signal);
2522
+ if (!killed)
2523
+ errors.push(`process group ${signal} returned false`);
2524
+ }
2525
+ catch (error) {
2526
+ if (typeof error === 'object' && error !== null && Reflect.get(error, 'code') === 'ESRCH') {
2527
+ this.finishPosixProcessGroupKill(task, state, true);
2528
+ }
2529
+ else {
2530
+ errors.push(`process group kill failed: ${BackgroundTaskRegistry.errorMessage(error)}`);
2531
+ }
2532
+ }
2533
+ if (!killed) {
2534
+ try {
2535
+ killed = task.child?.kill(signal) === true;
2536
+ if (!killed)
2537
+ errors.push(`child ${signal} returned false`);
2538
+ }
2539
+ catch (error) {
2540
+ errors.push(`child kill failed: ${BackgroundTaskRegistry.errorMessage(error)}`);
2541
+ }
2542
+ }
2543
+ if (!killed) {
2544
+ // If TERM reached neither target, waiting out grace has no benefit. Keep
2545
+ // the same owner but attempt its one force phase immediately.
2546
+ if (!state.settled)
2547
+ this.forceOwnedPosixProcessGroup(task, state);
2548
+ throw new Error(`Could not kill task ${task.id}: ${errors.join('; ')}`);
2549
+ }
2550
+ }
2551
+ async awaitPosixProcessGroupBeforeTerminal(task) {
2552
+ if (this.platform === 'win32')
2553
+ return undefined;
2554
+ const state = this.posixProcessGroupKillStates.get(task);
2555
+ if (state === undefined) {
2556
+ // No tree stop won the race before direct-child finalization. Release
2557
+ // signal authority synchronously so a concurrent late stop waits for this
2558
+ // terminalization instead of targeting a potentially reused group id.
2559
+ task.posixProcessGroupSignalAuthorityReleased = true;
2560
+ delete task.ownedPosixProcessGroupId;
2561
+ return undefined;
2562
+ }
2563
+ this.observeOwnedPosixProcessGroupGone(task, state);
2564
+ await state.completion;
2565
+ return state.failure;
2566
+ }
2567
+ waitForEndOrPosixForceFailure(task, timeoutMs) {
2568
+ const state = this.posixProcessGroupKillStates.get(task);
2569
+ if (state === undefined)
2570
+ return this.waitForEnd(task, timeoutMs);
2571
+ if (state.failure !== undefined)
2572
+ return Promise.reject(state.failure);
2573
+ if (task.status !== 'running')
2574
+ return Promise.resolve(true);
2575
+ return new Promise((resolve, reject) => {
2576
+ const cleanup = () => {
2577
+ clearTimeout(timeout);
2578
+ const waiterIndex = task.waiters.indexOf(done);
2579
+ if (waiterIndex >= 0)
2580
+ task.waiters.splice(waiterIndex, 1);
2581
+ const listeners = state.failureListeners;
2582
+ if (listeners !== undefined) {
2583
+ const listenerIndex = listeners.indexOf(failed);
2584
+ if (listenerIndex >= 0)
2585
+ listeners.splice(listenerIndex, 1);
2586
+ if (listeners.length === 0)
2587
+ delete state.failureListeners;
2588
+ }
2589
+ };
2590
+ const timeout = setTimeout(() => {
2591
+ cleanup();
2592
+ resolve(false);
2593
+ }, timeoutMs);
2594
+ const done = () => {
2595
+ cleanup();
2596
+ resolve(true);
2597
+ };
2598
+ const failed = (error) => {
2599
+ cleanup();
2600
+ reject(error);
2601
+ };
2602
+ task.waiters.push(done);
2603
+ if (state.failureListeners === undefined)
2604
+ state.failureListeners = [];
2605
+ state.failureListeners.push(failed);
2606
+ });
2607
+ }
2608
+ getWindowsKillState(task) {
2609
+ let state = this.windowsKillStates.get(task);
2610
+ if (state === undefined) {
2611
+ state = {};
2612
+ this.windowsKillStates.set(task, state);
2613
+ }
2614
+ return state;
2615
+ }
2616
+ static errorMessage(error) {
2617
+ return error instanceof Error ? error.message : String(error);
2618
+ }
2619
+ static appendTaskError(existing, next) {
2620
+ if (existing === undefined || existing.length === 0)
2621
+ return next;
2622
+ if (existing.includes(next))
2623
+ return existing;
2624
+ return `${existing}; ${next}`;
2625
+ }
2626
+ static describeTaskkillOutcome(outcome) {
2627
+ const exitCode = outcome.exitCode === null ? 'null' : String(outcome.exitCode);
2628
+ const signal = outcome.signal === null ? 'null' : outcome.signal;
2629
+ const stdout = outcome.stdout.length > 0 ? ` stdout=${JSON.stringify(outcome.stdout)}` : '';
2630
+ const stderr = outcome.stderr.length > 0 ? ` stderr=${JSON.stringify(outcome.stderr)}` : '';
2631
+ const stdoutTruncated = outcome.stdoutTruncated ? ' stdout_truncated=true' : '';
2632
+ const stderrTruncated = outcome.stderrTruncated ? ' stderr_truncated=true' : '';
2633
+ return `exit=${exitCode} signal=${signal}${stdout}${stderr}${stdoutTruncated}${stderrTruncated}`;
2634
+ }
2635
+ isWindowsTaskkillTerminalRace(task) {
2636
+ return task.status !== 'running' || task.finalized === true;
2637
+ }
2638
+ clearKillEscalationTimer(task) {
2639
+ if (task.killEscalationTimer !== undefined) {
2640
+ clearTimeout(task.killEscalationTimer);
2641
+ task.killEscalationTimer = undefined;
2642
+ }
2643
+ }
2644
+ recordWindowsTaskkillNotice(task, message) {
2645
+ this.writeNotice(task, `\n[background task Windows termination: ${message}]\n`);
2646
+ }
2647
+ recordWindowsSoftFailure(task, pid, detail) {
2648
+ const message = `Windows taskkill /T logical termination request failed for task ${task.id} pid ${String(pid)}: ` +
2649
+ `${detail}; force escalation remains scheduled`;
2650
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, message);
2651
+ this.recordWindowsTaskkillNotice(task, message);
2652
+ this.onChange();
2653
+ void this.writeMetadata(task).catch((metadataError) => {
2654
+ this.logger.error(`[background-tasks] failed to write Windows taskkill soft-failure metadata for ${task.id}:`, metadataError);
2655
+ });
2656
+ }
2657
+ makeWindowsForceFailure(task, pid, detail) {
2658
+ return new Error(`Windows taskkill /T /F force termination failed for task ${task.id} pid ${String(pid)}: ${detail}. Descendant processes may have leaked.`);
2659
+ }
2660
+ recordWindowsForceFailure(task, error) {
2661
+ const state = this.getWindowsKillState(task);
2662
+ state.forceFailure = error;
2663
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, error.message);
2664
+ this.recordWindowsTaskkillNotice(task, error.message);
2665
+ this.onChange();
2666
+ void this.writeMetadata(task).catch((metadataError) => {
2667
+ this.logger.error(`[background-tasks] failed to write Windows taskkill force-failure metadata for ${task.id}:`, metadataError);
2668
+ });
2669
+ const listeners = state.forceFailureListeners;
2670
+ if (listeners !== undefined) {
2671
+ delete state.forceFailureListeners;
2672
+ for (const listener of listeners)
2673
+ listener(error);
2674
+ }
2675
+ }
2676
+ evaluateWindowsTaskkillOutcome(task, pid, phase, outcome) {
2677
+ if (outcome.exitCode === 0)
2678
+ return undefined;
2679
+ const detail = BackgroundTaskRegistry.describeTaskkillOutcome(outcome);
2680
+ if (outcome.exitCode === 128) {
2681
+ this.recordWindowsTaskkillNotice(task, `taskkill ${phase} reported process not found for pid ${String(pid)} (${detail}); treating as an already-exited race`);
2682
+ return undefined;
2683
+ }
2684
+ if (this.isWindowsTaskkillTerminalRace(task)) {
2685
+ this.recordWindowsTaskkillNotice(task, `taskkill ${phase} finished after the task became terminal for pid ${String(pid)} (${detail}); treating as a terminal race`);
2686
+ return undefined;
2687
+ }
2688
+ if (phase === 'terminate') {
2689
+ this.recordWindowsSoftFailure(task, pid, detail);
2690
+ return undefined;
2691
+ }
2692
+ return this.makeWindowsForceFailure(task, pid, detail);
2693
+ }
2694
+ handleWindowsSoftException(task, pid, error, state) {
2695
+ const message = BackgroundTaskRegistry.errorMessage(error);
2696
+ if (state.forcePromise !== undefined || this.isWindowsTaskkillTerminalRace(task))
2697
+ return;
2698
+ this.recordWindowsSoftFailure(task, pid, message);
2699
+ }
2700
+ startWindowsSoftKill(task, pid) {
2701
+ const state = this.getWindowsKillState(task);
2702
+ if (state.softPromise !== undefined)
2703
+ return state.softPromise;
2704
+ const controller = new AbortController();
2705
+ state.softController = controller;
2706
+ let launched;
2707
+ try {
2708
+ launched = this.killTree(pid, 'terminate', controller.signal);
2709
+ }
2710
+ catch (error) {
2711
+ delete state.softController;
2712
+ throw new Error(`Could not kill task ${task.id}: Windows taskkill /T failed to start: ${BackgroundTaskRegistry.errorMessage(error)}`);
2713
+ }
2714
+ const promise = launched
2715
+ .then((outcome) => {
2716
+ if (state.forcePromise !== undefined || this.isWindowsTaskkillTerminalRace(task))
2717
+ return;
2718
+ const failure = this.evaluateWindowsTaskkillOutcome(task, pid, 'terminate', outcome);
2719
+ if (failure !== undefined)
2720
+ throw failure;
2721
+ })
2722
+ .catch((error) => {
2723
+ this.handleWindowsSoftException(task, pid, error, state);
2724
+ })
2725
+ .finally(() => {
2726
+ if (state.softController === controller)
2727
+ delete state.softController;
2728
+ });
2729
+ state.softPromise = promise;
2730
+ return promise;
2731
+ }
2732
+ startWindowsForceKill(task, pid) {
2733
+ const state = this.getWindowsKillState(task);
2734
+ if (state.forcePromise !== undefined)
2735
+ return state.forcePromise;
2736
+ let resolveForce;
2737
+ let rejectForce;
2738
+ const forcePromise = new Promise((resolve, reject) => {
2739
+ resolveForce = resolve;
2740
+ rejectForce = reject;
2741
+ });
2742
+ if (resolveForce === undefined || rejectForce === undefined) {
2743
+ throw new Error('Windows force termination promise could not be initialized');
2744
+ }
2745
+ const resolveForceReady = resolveForce;
2746
+ const rejectForceReady = rejectForce;
2747
+ state.forcePromise = forcePromise;
2748
+ void forcePromise.catch((error) => {
2749
+ this.logger.error(`[background-tasks] Windows force tree termination failed for ${task.id}:`, error);
2750
+ });
2751
+ this.clearKillEscalationTimer(task);
2752
+ if (state.softController !== undefined && !state.softController.signal.aborted) {
2753
+ state.softController.abort();
2754
+ }
2755
+ let launched;
2756
+ try {
2757
+ launched = this.killTree(pid, 'force');
2758
+ }
2759
+ catch (error) {
2760
+ const failure = this.makeWindowsForceFailure(task, pid, `helper failed to start: ${BackgroundTaskRegistry.errorMessage(error)}`);
2761
+ delete state.forcePromise;
2762
+ this.recordWindowsForceFailure(task, failure);
2763
+ rejectForceReady(failure);
2764
+ throw failure;
2765
+ }
2766
+ launched.then((outcome) => {
2767
+ const failure = this.evaluateWindowsTaskkillOutcome(task, pid, 'force', outcome);
2768
+ if (failure !== undefined) {
2769
+ this.recordWindowsForceFailure(task, failure);
2770
+ rejectForceReady(failure);
2771
+ return;
2772
+ }
2773
+ resolveForceReady();
2774
+ }, (error) => {
2775
+ if (this.isWindowsTaskkillTerminalRace(task)) {
2776
+ this.recordWindowsTaskkillNotice(task, `taskkill force rejected after the task became terminal for pid ${String(pid)} (${BackgroundTaskRegistry.errorMessage(error)}); treating as a terminal race`);
2777
+ resolveForceReady();
2778
+ return;
2779
+ }
2780
+ const failure = this.makeWindowsForceFailure(task, pid, BackgroundTaskRegistry.errorMessage(error));
2781
+ this.recordWindowsForceFailure(task, failure);
2782
+ rejectForceReady(failure);
2783
+ });
2784
+ return forcePromise;
2785
+ }
2786
+ requestWindowsKill(task, pid, signal) {
2787
+ if (signal === 'SIGKILL') {
2788
+ this.startWindowsForceKill(task, pid);
2789
+ task.killSignalSent = true;
2790
+ return;
2791
+ }
2792
+ this.startWindowsSoftKill(task, pid);
2793
+ task.killSignalSent = true;
2794
+ if (task.killEscalationTimer !== undefined)
2795
+ return;
2796
+ task.killEscalationTimer = setTimeout(() => {
2797
+ task.killEscalationTimer = undefined;
2798
+ if (task.status !== 'running')
2799
+ return;
2800
+ try {
2801
+ this.requestKill(task, 'SIGKILL');
2802
+ }
2803
+ catch (error) {
2804
+ task.error = BackgroundTaskRegistry.appendTaskError(task.error, `SIGKILL failed: ${error instanceof Error ? error.message : String(error)}`);
2805
+ void this.writeMetadata(task).catch((metadataError) => {
2806
+ this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
2807
+ });
2808
+ }
2809
+ }, this.killGraceMs).unref();
2810
+ }
2811
+ requestKill(task, signal = 'SIGTERM') {
2812
+ if (task.status !== 'running') {
2813
+ throw new Error(`Task ${task.id} is ${task.status}, not running`);
2814
+ }
2815
+ if (task.managedCancel !== undefined) {
2816
+ if (task.managedCancelRequested)
2817
+ return;
2818
+ task.managedCancelRequested = true;
2819
+ try {
2820
+ task.managedCancel();
2821
+ }
2822
+ catch (error) {
2823
+ throw new Error(`Could not cancel managed task ${task.id}: ${BackgroundTaskRegistry.errorMessage(error)}`);
2824
+ }
2825
+ task.killSignalSent = true;
2826
+ return;
2827
+ }
2828
+ if (!task.child) {
2829
+ throw new Error(`Task ${task.id} has no child process handle`);
2830
+ }
2831
+ if (task.killSignalSent && signal === 'SIGTERM')
2832
+ return;
2833
+ if (this.platform === 'win32') {
2834
+ if (!task.pid)
2835
+ throw new Error(`Task ${task.id} has no process id`);
2836
+ this.requestWindowsKill(task, task.pid, signal);
2837
+ return;
2838
+ }
2839
+ this.requestPosixKill(task, signal);
2840
+ }
2841
+ waitForEnd(task, timeoutMs) {
2842
+ if (task.status !== 'running')
2843
+ return Promise.resolve(true);
2844
+ return new Promise((resolve) => {
2845
+ const timeout = setTimeout(() => {
2846
+ const idx = task.waiters.indexOf(done);
2847
+ if (idx >= 0)
2848
+ task.waiters.splice(idx, 1);
2849
+ resolve(false);
2850
+ }, timeoutMs);
2851
+ const done = () => {
2852
+ clearTimeout(timeout);
2853
+ resolve(true);
2854
+ };
2855
+ task.waiters.push(done);
2856
+ });
2857
+ }
2858
+ waitForEndOrWindowsForceFailure(task, timeoutMs) {
2859
+ const state = this.getWindowsKillState(task);
2860
+ if (state.forceFailure !== undefined)
2861
+ return Promise.reject(state.forceFailure);
2862
+ if (task.status !== 'running')
2863
+ return Promise.resolve(true);
2864
+ return new Promise((resolve, reject) => {
2865
+ const cleanup = () => {
2866
+ clearTimeout(timeout);
2867
+ const waiterIndex = task.waiters.indexOf(done);
2868
+ if (waiterIndex >= 0)
2869
+ task.waiters.splice(waiterIndex, 1);
2870
+ const listeners = state.forceFailureListeners;
2871
+ if (listeners !== undefined) {
2872
+ const listenerIndex = listeners.indexOf(failed);
2873
+ if (listenerIndex >= 0)
2874
+ listeners.splice(listenerIndex, 1);
2875
+ if (listeners.length === 0)
2876
+ delete state.forceFailureListeners;
2877
+ }
2878
+ };
2879
+ const timeout = setTimeout(() => {
2880
+ cleanup();
2881
+ resolve(false);
2882
+ }, timeoutMs);
2883
+ const done = () => {
2884
+ cleanup();
2885
+ resolve(true);
2886
+ };
2887
+ const failed = (error) => {
2888
+ cleanup();
2889
+ reject(error);
2890
+ };
2891
+ task.waiters.push(done);
2892
+ if (state.forceFailureListeners === undefined)
2893
+ state.forceFailureListeners = [];
2894
+ state.forceFailureListeners.push(failed);
2895
+ });
2896
+ }
2897
+ async awaitWindowsForceBeforeTerminal(task) {
2898
+ const state = this.windowsKillStates.get(task);
2899
+ if (state === undefined)
2900
+ return undefined;
2901
+ const forcePromise = state.forcePromise;
2902
+ if (forcePromise === undefined)
2903
+ return state.forceFailure;
2904
+ try {
2905
+ await forcePromise;
2906
+ }
2907
+ catch (error) {
2908
+ return error instanceof Error ? error : new Error(String(error));
2909
+ }
2910
+ return state.forceFailure;
2911
+ }
2912
+ async deliverReloadTerminal(execution, lease) {
2913
+ const task = execution.task;
2914
+ if (task.reloadHostDeliveryInFlight || task.reloadHostDeliverySettled) {
2915
+ this.maybeReleaseReloadExecution(task);
2916
+ return;
2917
+ }
2918
+ task.reloadHostDeliveryInFlight = true;
2919
+ try {
2920
+ if (!this.ownsReloadExecution(execution, lease))
2921
+ return;
2922
+ this.onChange();
2923
+ this.publishTerminal(task);
2924
+ const deliveryGate = await this.waitForTerminalPublicationGate(task);
2925
+ if (!this.ownsReloadExecution(execution, lease))
2926
+ return;
2927
+ if (deliveryGate.kind === 'rejected') {
2928
+ this.logger.error(`[background-tasks] completion delivery gate failed for ${task.id}: ${this.terminalPublicationError(deliveryGate.error)}`);
2929
+ }
2930
+ else if (task.notifyOnCompletion &&
2931
+ !task.notified &&
2932
+ !this.shuttingDown &&
2933
+ execution.notificationState === 'pending') {
2934
+ const token = execution.beginNotification(lease);
2935
+ if (token !== undefined) {
2936
+ try {
2937
+ this.notifyCompletion(task);
2938
+ execution.finishNotification(token, task.notified);
2939
+ }
2940
+ catch (error) {
2941
+ execution.finishNotification(token, false);
2942
+ this.logger.error(`[background-tasks] notification failed for ${task.id}:`, error);
2943
+ }
2944
+ }
2945
+ }
2946
+ if (!this.ownsReloadExecution(execution, lease))
2947
+ return;
2948
+ task.reloadHostNotificationSettled = true;
2949
+ task.reloadHostDeliverySettled = true;
2950
+ try {
2951
+ await this.writeMetadata(task);
2952
+ }
2953
+ catch (error) {
2954
+ this.logger.error(`[background-tasks] failed to update survivor notification metadata for ${task.id}:`, error);
2955
+ }
2956
+ }
2957
+ finally {
2958
+ task.reloadHostDeliveryInFlight = false;
2959
+ this.maybeReleaseReloadExecution(task);
2960
+ }
2961
+ }
2962
+ maybeReleaseReloadExecution(task) {
2963
+ const execution = task.reloadExecution;
2964
+ const lease = this.reloadShellLease;
2965
+ if (execution === undefined ||
2966
+ execution.phase !== 'terminal' ||
2967
+ task.reloadHostNotificationSettled !== true ||
2968
+ task.terminalPublicationState === 'pending' ||
2969
+ !this.ownsReloadExecution(execution, lease) ||
2970
+ this.reloadShellOwner === undefined ||
2971
+ lease === undefined) {
2972
+ return;
2973
+ }
2974
+ try {
2975
+ this.reloadShellOwner.releaseExecution(lease, execution);
2976
+ }
2977
+ catch (error) {
2978
+ if (typeof error !== 'object' ||
2979
+ error === null ||
2980
+ Reflect.get(error, 'code') !== 'pi_bg_reload_owner_stale_claim') {
2981
+ this.logger.error(`[background-tasks] failed to release reload shell execution ${task.id}:`, error);
2982
+ }
2983
+ }
2984
+ }
2985
+ terminalPublicationAbandonSignal(task) {
2986
+ const existing = this.terminalPublicationAbandonSignals.get(task);
2987
+ if (existing !== undefined)
2988
+ return existing;
2989
+ let resolveSignal = () => { };
2990
+ const promise = new Promise((resolve) => {
2991
+ resolveSignal = resolve;
2992
+ });
2993
+ const signal = { promise, resolve: resolveSignal };
2994
+ this.terminalPublicationAbandonSignals.set(task, signal);
2995
+ return signal;
2996
+ }
2997
+ publishTerminal(task) {
2998
+ if (task.reloadExecution !== undefined && this.tasks.get(task.id) !== task)
2999
+ return;
3000
+ if (task.terminalPublicationState !== 'pending' || task.terminalPublishInFlight)
3001
+ return;
3002
+ if (this.terminalPublicationClosed) {
3003
+ this.abandonTerminalPublication(task, this.terminalPublicationCloseReason ?? 'registry_shutdown');
3004
+ return;
3005
+ }
3006
+ task.terminalPublishInFlight = true;
3007
+ if (task.terminalPublicationGate === undefined) {
3008
+ this.tryPublishTerminalNow(task);
3009
+ return;
3010
+ }
3011
+ void this.publishTerminalWhenReady(task);
3012
+ }
3013
+ async publishTerminalWhenReady(task) {
3014
+ const outcome = await this.waitForTerminalPublicationGate(task);
3015
+ if (task.reloadExecution !== undefined && this.tasks.get(task.id) !== task) {
3016
+ task.terminalPublishInFlight = false;
3017
+ return;
3018
+ }
3019
+ if (outcome.kind === 'closed') {
3020
+ this.abandonTerminalPublication(task, outcome.reason);
3021
+ return;
3022
+ }
3023
+ if (outcome.kind === 'rejected') {
3024
+ this.abandonTerminalPublication(task, 'gate_rejected', outcome.error);
3025
+ return;
3026
+ }
3027
+ // The gate and registry closure can settle in the same microtask turn.
3028
+ // Re-check after the await so a late gate cannot publish into a disposed
3029
+ // activation or revive a task already abandoned by shutdown.
3030
+ if (this.terminalPublicationClosed || task.terminalPublicationState !== 'pending') {
3031
+ this.abandonTerminalPublication(task, this.terminalPublicationCloseReason ?? 'registry_shutdown');
3032
+ return;
3033
+ }
3034
+ this.tryPublishTerminalNow(task);
3035
+ }
3036
+ async waitForTerminalPublicationGate(task) {
3037
+ if (this.terminalPublicationClosed) {
3038
+ return {
3039
+ kind: 'closed',
3040
+ reason: this.terminalPublicationCloseReason ?? 'registry_shutdown',
3041
+ };
3042
+ }
3043
+ if (task.terminalPublicationState === 'abandoned') {
3044
+ if (task.terminalPublicationAbandonReason === 'gate_rejected') {
3045
+ return { kind: 'rejected', error: new Error('terminal publication gate rejected') };
3046
+ }
3047
+ return {
3048
+ kind: 'closed',
3049
+ reason: task.terminalPublicationAbandonReason ?? 'registry_shutdown',
3050
+ };
3051
+ }
3052
+ const gate = task.terminalPublicationGate;
3053
+ if (gate === undefined)
3054
+ return { kind: 'released' };
3055
+ const gateOutcome = gate.then(() => ({ kind: 'released' }), (error) => ({ kind: 'rejected', error }));
3056
+ const closureOutcome = this.terminalPublicationClosedSignal.then((reason) => ({ kind: 'closed', reason }));
3057
+ const abandonmentOutcome = this.terminalPublicationAbandonSignal(task).promise.then((reason) => ({
3058
+ kind: 'closed',
3059
+ reason,
3060
+ }));
3061
+ const outcome = await Promise.race([gateOutcome, closureOutcome, abandonmentOutcome]);
3062
+ // Closure wins if it happened before this continuation resumed, regardless
3063
+ // of which promise queued its reaction first.
3064
+ if (this.terminalPublicationClosed) {
3065
+ return {
3066
+ kind: 'closed',
3067
+ reason: this.terminalPublicationCloseReason ?? 'registry_shutdown',
3068
+ };
3069
+ }
3070
+ return outcome;
3071
+ }
3072
+ tryPublishTerminalNow(task) {
3073
+ if (task.reloadExecution !== undefined && this.tasks.get(task.id) !== task) {
3074
+ task.terminalPublishInFlight = false;
3075
+ return;
3076
+ }
3077
+ if (task.terminalPublicationState !== 'pending') {
3078
+ task.terminalPublishInFlight = false;
3079
+ return;
3080
+ }
3081
+ if (this.terminalPublicationClosed) {
3082
+ task.terminalPublishInFlight = false;
3083
+ this.abandonTerminalPublication(task, this.terminalPublicationCloseReason ?? 'registry_shutdown');
3084
+ this.pruneOldTasks();
3085
+ return;
3086
+ }
3087
+ task.terminalPublishAttempts += 1;
3088
+ task.terminalEmitInFlight = true;
3089
+ let emitFailed = false;
3090
+ let emitError;
3091
+ try {
3092
+ this.publishTerminalSnapshot(snapshot(task));
3093
+ }
3094
+ catch (error) {
3095
+ emitFailed = true;
3096
+ emitError = error;
3097
+ }
3098
+ task.terminalEmitInFlight = false;
3099
+ task.terminalPublishInFlight = false;
3100
+ if (emitFailed) {
3101
+ const execution = task.reloadExecution;
3102
+ if (execution !== undefined && !this.ownsReloadExecution(execution, this.reloadShellLease)) {
3103
+ // The emitter synchronously detached this task into a reload handoff
3104
+ // before throwing. Attempt 1 is consumed, but only the fresh owner may
3105
+ // retry or decide abandonment on the shared publication ledger.
3106
+ return;
3107
+ }
3108
+ this.handleTerminalPublishFailure(task, emitError);
3109
+ }
3110
+ else {
3111
+ this.markTerminalPublicationDelivered(task);
3112
+ }
3113
+ this.pruneOldTasks();
3114
+ }
3115
+ markTerminalPublicationDelivered(task) {
3116
+ if (task.terminalPublishRetryHandle !== undefined) {
3117
+ clearTimeout(task.terminalPublishRetryHandle);
3118
+ task.terminalPublishRetryHandle = undefined;
3119
+ }
3120
+ task.terminalPublicationGate = undefined;
3121
+ task.terminalEmitInFlight = false;
3122
+ task.terminalPublishInFlight = false;
3123
+ task.terminalPublicationState = 'delivered';
3124
+ delete task.terminalPublicationAbandonReason;
3125
+ task.terminalPublished = true;
3126
+ this.maybeReleaseReloadExecution(task);
3127
+ }
3128
+ abandonTerminalPublication(task, reason, error, log = true) {
3129
+ if (task.terminalPublishRetryHandle !== undefined) {
3130
+ clearTimeout(task.terminalPublishRetryHandle);
3131
+ task.terminalPublishRetryHandle = undefined;
3132
+ }
3133
+ task.terminalPublicationGate = undefined;
3134
+ if (task.terminalEmitInFlight !== true)
3135
+ task.terminalPublishInFlight = false;
3136
+ if (task.terminalPublicationState === 'delivered')
3137
+ return;
3138
+ if (task.terminalPublicationState === 'abandoned')
3139
+ return;
3140
+ task.terminalPublicationState = 'abandoned';
3141
+ task.terminalPublicationAbandonReason = reason;
3142
+ task.terminalPublished = false;
3143
+ this.terminalPublicationAbandonSignal(task).resolve(reason);
3144
+ if (log) {
3145
+ const detail = error === undefined ? '' : `: ${this.terminalPublicationError(error)}`;
3146
+ this.logger.error(`[background-tasks] terminal publication abandoned for ${task.id} (${reason}) after ${String(task.terminalPublishAttempts)}/${String(TERMINAL_PUBLICATION_MAX_ATTEMPTS)} emit attempts${detail}`);
3147
+ }
3148
+ this.maybeReleaseReloadExecution(task);
3149
+ }
3150
+ terminalPublicationError(error) {
3151
+ const compact = BackgroundTaskRegistry.errorMessage(error).replace(/\s+/gu, ' ').trim();
3152
+ if (compact.length <= TERMINAL_PUBLICATION_DIAGNOSTIC_CHARS)
3153
+ return compact;
3154
+ return `${compact.slice(0, TERMINAL_PUBLICATION_DIAGNOSTIC_CHARS - 1)}…`;
3155
+ }
3156
+ handleTerminalPublishFailure(task, error) {
3157
+ task.terminalPublishInFlight = false;
3158
+ if (task.terminalPublicationState !== 'pending')
3159
+ return;
3160
+ if (error instanceof BackgroundTaskExtensionServiceClosedError) {
3161
+ this.closeTerminalPublication('publisher_closed');
3162
+ return;
3163
+ }
3164
+ if (this.terminalPublicationClosed) {
3165
+ this.abandonTerminalPublication(task, this.terminalPublicationCloseReason ?? 'registry_shutdown', error);
3166
+ this.pruneOldTasks();
3167
+ return;
3168
+ }
3169
+ if (task.terminalPublishAttempts >= TERMINAL_PUBLICATION_MAX_ATTEMPTS) {
3170
+ this.abandonTerminalPublication(task, 'retry_exhausted', error);
3171
+ this.pruneOldTasks();
3172
+ return;
3173
+ }
3174
+ this.logger.error(`[background-tasks] terminal publication failed for ${task.id} (attempt ${String(task.terminalPublishAttempts)}/${String(TERMINAL_PUBLICATION_MAX_ATTEMPTS)}; retrying): ${this.terminalPublicationError(error)}`);
3175
+ if (task.terminalPublishRetryHandle !== undefined)
3176
+ return;
3177
+ task.terminalPublishRetryHandle = setTimeout(() => {
3178
+ task.terminalPublishRetryHandle = undefined;
3179
+ if (this.terminalPublicationClosed ||
3180
+ task.terminalPublicationState !== 'pending' ||
3181
+ (task.reloadExecution !== undefined && this.tasks.get(task.id) !== task))
3182
+ return;
3183
+ this.publishTerminal(task);
3184
+ }, TERMINAL_PUBLICATION_RETRY_MS);
3185
+ task.terminalPublishRetryHandle.unref();
3186
+ }
3187
+ notifyCompletion(task) {
3188
+ if (!task.notifyOnCompletion || task.notified || this.shuttingDown)
3189
+ return;
3190
+ task.notified = true;
3191
+ const exit = task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
3192
+ const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
3193
+ const taskName = taskDisplayName(task);
3194
+ const guidance = task.fusion === undefined
3195
+ ? 'Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.'
3196
+ : task.status === 'completed'
3197
+ ? `Fusion result is durably committed at ${task.fusion.artifactDir}. Call bg_result({taskId:${JSON.stringify(task.id)}}) once to retrieve it; do not poll.`
3198
+ : `Fusion ended ${task.status}. Inspect the preserved artifacts at ${task.fusion.artifactDir}; do not poll.`;
3199
+ const content = [
3200
+ '<background-task-notification>',
3201
+ ` <task-id>${task.id}</task-id>`,
3202
+ ` <task-name>${escapeXml(taskName)}</task-name>`,
3203
+ ` <status>${task.status}</status>`,
3204
+ exit,
3205
+ error,
3206
+ ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
3207
+ ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
3208
+ ` <guidance>${escapeXml(guidance)}</guidance>`,
3209
+ '</background-task-notification>',
3210
+ ]
3211
+ .filter(Boolean)
3212
+ .join('\n');
3213
+ try {
3214
+ this.sendCompletionNotification({
3215
+ customType: 'background-task-notification',
3216
+ content,
3217
+ display: true,
3218
+ details: snapshot(task),
3219
+ }, { deliverAs: 'followUp', triggerTurn: task.triggerOnCompletion });
3220
+ }
3221
+ catch (error) {
3222
+ task.notified = false;
3223
+ throw new Error(`Failed to send background task notification for ${task.id}: ${error instanceof Error ? error.message : String(error)}`);
3224
+ }
3225
+ }
3226
+ async finalizeTask(task, status, exitCode, signal, error) {
3227
+ if (task.finalized)
3228
+ return;
3229
+ task.finalized = true;
3230
+ if (task.timeoutHandle)
3231
+ clearTimeout(task.timeoutHandle);
3232
+ if (this.platform === 'win32')
3233
+ this.clearKillEscalationTimer(task);
3234
+ let finalStatus = status;
3235
+ let finalError = error;
3236
+ const posixForceFailure = await this.awaitPosixProcessGroupBeforeTerminal(task);
3237
+ if (posixForceFailure !== undefined) {
3238
+ finalStatus = 'failed';
3239
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, posixForceFailure.message);
3240
+ }
3241
+ const windowsForceFailure = await this.awaitWindowsForceBeforeTerminal(task);
3242
+ if (windowsForceFailure !== undefined) {
3243
+ finalStatus = 'failed';
3244
+ finalError = BackgroundTaskRegistry.appendTaskError(finalError, windowsForceFailure.message);
3245
+ }
3246
+ task.exitCode = exitCode;
3247
+ task.signal = signal ?? null;
3248
+ // Keep status="running" until the final wrapped-agent fragment has been
3249
+ // consumed and the output plus terminal metadata are durable. Publishing a
3250
+ // terminal state earlier lets bg_status observe the previous assistant
3251
+ // turn's context snapshot and recreates the same false-completion race the
3252
+ // attested producer is required to prevent.
3253
+ try {
3254
+ if (task.telemetryWrapped) {
3255
+ // Child-process close can be observed before the wrapper stdout listener has
3256
+ // committed its last parsed telemetry batch. Wait for a short quiet window,
3257
+ // then flush the trailing partial line, so completed status never races
3258
+ // ahead of the final assistant-turn context/token/tool snapshot.
3259
+ await new Promise((resolve) => setTimeout(resolve, 25));
3260
+ this.flushAgentStdout(task);
3261
+ }
3262
+ if (task.stream && !task.stream.destroyed)
3263
+ await closeAndFsyncOutputStream(task.stream);
3264
+ }
3265
+ catch (finalizeError) {
3266
+ finalStatus = 'failed';
3267
+ const message = finalizeError instanceof Error ? finalizeError.message : String(finalizeError);
3268
+ finalError = finalError
3269
+ ? `${finalError}; final output durability failed: ${message}`
3270
+ : `Final output durability failed: ${message}`;
3271
+ }
3272
+ task.endTime = this.now();
3273
+ if (finalError)
3274
+ task.error = finalError;
3275
+ try {
3276
+ await this.writeMetadataSnapshot(task, { ...snapshot(task), status: finalStatus });
3277
+ task.status = finalStatus;
3278
+ }
3279
+ catch (metadataError) {
3280
+ finalStatus = 'failed';
3281
+ task.status = 'failed';
3282
+ task.error = `Terminal metadata write failed: ${metadataError instanceof Error ? metadataError.message : String(metadataError)}`;
3283
+ this.logger.error(`[background-tasks] failed to write metadata for ${task.id}:`, metadataError);
3284
+ await this.writeMetadata(task).catch((retryError) => {
3285
+ this.logger.error(`[background-tasks] failed to write failed terminal metadata for ${task.id}:`, retryError);
3286
+ });
3287
+ }
3288
+ for (const waiter of task.waiters.splice(0))
3289
+ waiter();
3290
+ this.onChange();
3291
+ this.publishTerminal(task);
3292
+ const deliveryGate = await this.waitForTerminalPublicationGate(task);
3293
+ if (deliveryGate.kind === 'rejected') {
3294
+ this.logger.error(`[background-tasks] completion delivery gate failed for ${task.id}: ${this.terminalPublicationError(deliveryGate.error)}`);
3295
+ }
3296
+ else {
3297
+ // EventBus disposal abandons only EventBus publication. Notification truth
3298
+ // remains independent; notifyCompletion itself suppresses session shutdown.
3299
+ try {
3300
+ this.notifyCompletion(task);
3301
+ }
3302
+ catch (notificationError) {
3303
+ this.logger.error(`[background-tasks] notification failed for ${task.id}:`, notificationError);
3304
+ }
3305
+ }
3306
+ try {
3307
+ await this.writeMetadata(task);
3308
+ }
3309
+ catch (metadataError) {
3310
+ this.logger.error(`[background-tasks] failed to update notification metadata for ${task.id}:`, metadataError);
3311
+ }
3312
+ this.pruneOldTasks();
3313
+ }
3314
+ pruneOldTasks() {
3315
+ if (this.tasks.size <= this.maxRecentTasks)
3316
+ return;
3317
+ const removable = [...this.tasks.values()]
3318
+ .filter((task) => task.status !== 'running' && task.terminalEmitInFlight !== true)
3319
+ .sort((a, b) => (a.endTime ?? a.startTime) - (b.endTime ?? b.startTime));
3320
+ while (this.tasks.size > this.maxRecentTasks && removable.length > 0) {
3321
+ const task = removable.shift();
3322
+ if (task === undefined)
3323
+ continue;
3324
+ if (task.terminalPublicationState === 'pending') {
3325
+ this.abandonTerminalPublication(task, 'retention_limit');
3326
+ }
3327
+ this.tasks.delete(task.id);
3328
+ }
3329
+ }
3330
+ }
3331
+ //# sourceMappingURL=registry.js.map