pi-background-tasks 1.0.7 → 2.0.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 (45) hide show
  1. package/README.md +7 -7
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +2 -2
  4. package/docs/INDEX.md +25 -25
  5. package/docs/choose-a-workflow.md +4 -4
  6. package/docs/commands/bg-clear.md +1 -1
  7. package/docs/commands/bg-update.md +1 -1
  8. package/docs/commands/bg.md +1 -1
  9. package/docs/commands/fusion-models.md +1 -1
  10. package/docs/commands/fusion.md +5 -8
  11. package/docs/commands/jobs.md +1 -1
  12. package/docs/commands/kill.md +1 -1
  13. package/docs/commands/logs.md +1 -1
  14. package/docs/commands/task-manager.md +2 -2
  15. package/docs/concepts/completion-delivery.md +1 -0
  16. package/docs/getting-started.md +1 -1
  17. package/docs/manifest.json +59 -50
  18. package/docs/read-before-edit.md +1 -0
  19. package/docs/reference/runtime-contracts.md +48 -45
  20. package/docs/reference/shortcuts-and-dock.md +2 -2
  21. package/docs/subsystems/background-task-runtime.md +7 -1
  22. package/docs/subsystems/docs-freshness-gate.md +4 -4
  23. package/docs/subsystems/fusion.md +13 -9
  24. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  25. package/docs/tools/bg_delegate.md +1 -1
  26. package/docs/tools/bg_kill.md +1 -1
  27. package/docs/tools/bg_logs.md +1 -1
  28. package/docs/tools/bg_result.md +14 -10
  29. package/docs/tools/bg_run.md +1 -1
  30. package/docs/tools/bg_run_pi_attested.md +1 -1
  31. package/docs/tools/bg_status.md +1 -1
  32. package/docs/tools/fusion_investigate.md +6 -4
  33. package/docs/tools/fusion_reason.md +5 -5
  34. package/docs/tools/fusion_research.md +6 -2
  35. package/docs/tools/fusion_validate.md +5 -3
  36. package/package.json +1 -1
  37. package/src/core/common.ts +50 -2
  38. package/src/core/fusion/artifacts.ts +72 -20
  39. package/src/core/fusion/orchestrator.ts +154 -68
  40. package/src/core/fusion/result-package.ts +385 -0
  41. package/src/core/fusion/types.ts +9 -0
  42. package/src/core/registry.ts +187 -20
  43. package/src/delegate-extension.ts +130 -24
  44. package/src/extension.ts +17 -6
  45. package/src/fusion-extension.ts +308 -154
@@ -0,0 +1,385 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { sha256Buffer } from '../attested-pi-run.js';
4
+ import { parseJsonText, type JsonObject } from '../common.js';
5
+ import {
6
+ FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
7
+ FUSION_MANIFEST_SCHEMA_VERSION,
8
+ FUSION_RESULT_SCHEMA_VERSION,
9
+ FusionError,
10
+ type FusionArtifactRef,
11
+ type FusionResultDetails,
12
+ type FusionRunResult,
13
+ type FusionUsage,
14
+ type FusionWorkflowId,
15
+ } from './types.js';
16
+
17
+ const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
18
+
19
+ function isRecord(value: unknown): value is JsonObject {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+
23
+ function fail(message: string, artifactDir: string): never {
24
+ throw new FusionError(`fusion committed result invalid: ${message}`, {
25
+ code: 'artifact_error',
26
+ childCreated: true,
27
+ artifactDir,
28
+ });
29
+ }
30
+
31
+ function assertOnlyKeys(
32
+ value: JsonObject,
33
+ allowed: readonly string[],
34
+ label: string,
35
+ artifactDir: string,
36
+ ): void {
37
+ const unexpected = Object.keys(value).filter((key) => !allowed.includes(key));
38
+ if (unexpected.length > 0)
39
+ fail(`${label} contains unexpected keys: ${unexpected.join(', ')}`, artifactDir);
40
+ }
41
+
42
+ function artifactRef(value: unknown, label: string, artifactDir: string): FusionArtifactRef {
43
+ if (!isRecord(value)) fail(`${label} must be an object`, artifactDir);
44
+ assertOnlyKeys(value, ['path', 'byte_length', 'sha256'], label, artifactDir);
45
+ const path = value['path'];
46
+ const byteLength = value['byte_length'];
47
+ const sha256 = value['sha256'];
48
+ if (typeof path !== 'string' || path.length === 0 || path.includes('/') || path.includes('\\')) {
49
+ fail(`${label}.path is invalid`, artifactDir);
50
+ }
51
+ if (!Number.isSafeInteger(byteLength) || Number(byteLength) < 0) {
52
+ fail(`${label}.byte_length is invalid`, artifactDir);
53
+ }
54
+ if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) {
55
+ fail(`${label}.sha256 is invalid`, artifactDir);
56
+ }
57
+ return { path, byte_length: Number(byteLength), sha256 };
58
+ }
59
+
60
+ function usage(value: unknown, artifactDir: string): FusionUsage {
61
+ if (!isRecord(value)) fail('details.usage must be an object', artifactDir);
62
+ assertOnlyKeys(
63
+ value,
64
+ ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens', 'cost'],
65
+ 'details.usage',
66
+ artifactDir,
67
+ );
68
+ const cost = value['cost'];
69
+ if (!isRecord(cost)) fail('details.usage.cost must be an object', artifactDir);
70
+ assertOnlyKeys(
71
+ cost,
72
+ ['input', 'output', 'cacheRead', 'cacheWrite', 'total'],
73
+ 'details.usage.cost',
74
+ artifactDir,
75
+ );
76
+ const finiteNonnegative = (entry: unknown, label: string): number => {
77
+ if (typeof entry !== 'number' || !Number.isFinite(entry) || entry < 0)
78
+ fail(`${label} is invalid`, artifactDir);
79
+ return entry;
80
+ };
81
+ return {
82
+ input: finiteNonnegative(value['input'], 'details.usage.input'),
83
+ output: finiteNonnegative(value['output'], 'details.usage.output'),
84
+ cacheRead: finiteNonnegative(value['cacheRead'], 'details.usage.cacheRead'),
85
+ cacheWrite: finiteNonnegative(value['cacheWrite'], 'details.usage.cacheWrite'),
86
+ totalTokens: finiteNonnegative(value['totalTokens'], 'details.usage.totalTokens'),
87
+ cost: {
88
+ input: finiteNonnegative(cost['input'], 'details.usage.cost.input'),
89
+ output: finiteNonnegative(cost['output'], 'details.usage.cost.output'),
90
+ cacheRead: finiteNonnegative(cost['cacheRead'], 'details.usage.cost.cacheRead'),
91
+ cacheWrite: finiteNonnegative(cost['cacheWrite'], 'details.usage.cost.cacheWrite'),
92
+ total: finiteNonnegative(cost['total'], 'details.usage.cost.total'),
93
+ },
94
+ };
95
+ }
96
+
97
+ function resultDetails(
98
+ value: unknown,
99
+ expected: { runId: string; workflow: FusionWorkflowId; artifactDir: string },
100
+ ): FusionResultDetails {
101
+ if (!isRecord(value)) fail('details must be an object', expected.artifactDir);
102
+ assertOnlyKeys(
103
+ value,
104
+ [
105
+ 'schema_version',
106
+ 'run_id',
107
+ 'workflow',
108
+ 'source',
109
+ 'status',
110
+ 'context',
111
+ 'tool_policy',
112
+ 'artifact_dir',
113
+ 'models',
114
+ 'evaluator_attempts',
115
+ 'usage',
116
+ 'budget',
117
+ ],
118
+ 'details',
119
+ expected.artifactDir,
120
+ );
121
+ if (value['schema_version'] !== FUSION_RESULT_SCHEMA_VERSION)
122
+ fail('details schema version mismatch', expected.artifactDir);
123
+ if (value['run_id'] !== expected.runId) fail('details run id mismatch', expected.artifactDir);
124
+ if (value['workflow'] !== expected.workflow)
125
+ fail('details workflow mismatch', expected.artifactDir);
126
+ if (value['source'] !== 'command' && value['source'] !== 'tool')
127
+ fail('details source is invalid', expected.artifactDir);
128
+ if (value['status'] !== 'completed')
129
+ fail('details status is not completed', expected.artifactDir);
130
+ if (value['artifact_dir'] !== expected.artifactDir)
131
+ fail('details artifact directory mismatch', expected.artifactDir);
132
+ const context = value['context'];
133
+ const toolPolicy = value['tool_policy'];
134
+ const models = value['models'];
135
+ const budget = value['budget'];
136
+ if (!isRecord(context) || !isRecord(toolPolicy) || !isRecord(models) || !isRecord(budget)) {
137
+ fail('details nested contract is malformed', expected.artifactDir);
138
+ }
139
+ assertOnlyKeys(context, ['kind', 'policy_id'], 'details.context', expected.artifactDir);
140
+ if (
141
+ (context['kind'] !== 'session_projection' && context['kind'] !== 'clean_task') ||
142
+ typeof context['policy_id'] !== 'string'
143
+ ) {
144
+ fail('details.context is invalid', expected.artifactDir);
145
+ }
146
+ assertOnlyKeys(
147
+ toolPolicy,
148
+ ['candidate_tools', 'evaluation_tools', 'merge_tools'],
149
+ 'details.tool_policy',
150
+ expected.artifactDir,
151
+ );
152
+ const stringArray = (entry: unknown): entry is string[] =>
153
+ Array.isArray(entry) && entry.every((item) => typeof item === 'string');
154
+ if (
155
+ !stringArray(toolPolicy['candidate_tools']) ||
156
+ !Array.isArray(toolPolicy['evaluation_tools']) ||
157
+ toolPolicy['evaluation_tools'].length !== 0 ||
158
+ !Array.isArray(toolPolicy['merge_tools']) ||
159
+ toolPolicy['merge_tools'].length !== 0
160
+ ) {
161
+ fail('details.tool_policy is invalid', expected.artifactDir);
162
+ }
163
+ assertOnlyKeys(
164
+ models,
165
+ ['candidates', 'evaluator', 'merger', 'thinking_level'],
166
+ 'details.models',
167
+ expected.artifactDir,
168
+ );
169
+ if (
170
+ !stringArray(models['candidates']) ||
171
+ models['candidates'].length !== 3 ||
172
+ typeof models['evaluator'] !== 'string' ||
173
+ typeof models['merger'] !== 'string' ||
174
+ typeof models['thinking_level'] !== 'string'
175
+ ) {
176
+ fail('details.models is invalid', expected.artifactDir);
177
+ }
178
+ assertOnlyKeys(
179
+ budget,
180
+ [
181
+ 'policy_id',
182
+ 'calibration_version',
183
+ 'route_table',
184
+ 'rate_sources',
185
+ 'unknown_provider_warnings',
186
+ 'calibration_warnings',
187
+ ],
188
+ 'details.budget',
189
+ expected.artifactDir,
190
+ );
191
+ if (
192
+ typeof budget['policy_id'] !== 'string' ||
193
+ typeof budget['calibration_version'] !== 'string' ||
194
+ !Array.isArray(budget['route_table']) ||
195
+ !Array.isArray(budget['rate_sources']) ||
196
+ !stringArray(budget['unknown_provider_warnings']) ||
197
+ !Array.isArray(budget['calibration_warnings'])
198
+ ) {
199
+ fail('details.budget is invalid', expected.artifactDir);
200
+ }
201
+ if (
202
+ !Number.isSafeInteger(value['evaluator_attempts']) ||
203
+ ![1, 2].includes(Number(value['evaluator_attempts']))
204
+ ) {
205
+ fail('details evaluator_attempts is invalid', expected.artifactDir);
206
+ }
207
+ const checkedUsage = usage(value['usage'], expected.artifactDir);
208
+ const candidates = models['candidates'];
209
+ if (!stringArray(candidates) || candidates.length !== 3)
210
+ fail('details.models candidates are invalid', expected.artifactDir);
211
+ const candidate1 = candidates[0];
212
+ const candidate2 = candidates[1];
213
+ const candidate3 = candidates[2];
214
+ if (candidate1 === undefined || candidate2 === undefined || candidate3 === undefined) {
215
+ fail('details.models candidates are incomplete', expected.artifactDir);
216
+ }
217
+ const source = value['source'];
218
+ const contextKind = context['kind'];
219
+ return {
220
+ schema_version: FUSION_RESULT_SCHEMA_VERSION,
221
+ run_id: expected.runId,
222
+ workflow: expected.workflow,
223
+ source,
224
+ status: 'completed',
225
+ context: { kind: contextKind, policy_id: context['policy_id'] },
226
+ tool_policy: {
227
+ candidate_tools: [...toolPolicy['candidate_tools']],
228
+ evaluation_tools: [],
229
+ merge_tools: [],
230
+ },
231
+ artifact_dir: expected.artifactDir,
232
+ models: {
233
+ candidates: [candidate1, candidate2, candidate3],
234
+ evaluator: models['evaluator'],
235
+ merger: models['merger'],
236
+ thinking_level: models['thinking_level'],
237
+ },
238
+ evaluator_attempts: Number(value['evaluator_attempts']),
239
+ usage: checkedUsage,
240
+ budget: {
241
+ policy_id: budget['policy_id'],
242
+ calibration_version: budget['calibration_version'],
243
+ route_table: budget['route_table'] as FusionResultDetails['budget']['route_table'],
244
+ rate_sources: budget['rate_sources'] as FusionResultDetails['budget']['rate_sources'],
245
+ unknown_provider_warnings: [...budget['unknown_provider_warnings']],
246
+ calibration_warnings: budget[
247
+ 'calibration_warnings'
248
+ ] as FusionResultDetails['budget']['calibration_warnings'],
249
+ },
250
+ };
251
+ }
252
+
253
+ function sameRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
254
+ return (
255
+ left.path === right.path &&
256
+ left.byte_length === right.byte_length &&
257
+ left.sha256 === right.sha256
258
+ );
259
+ }
260
+
261
+ async function readUtf8(
262
+ path: string,
263
+ label: string,
264
+ artifactDir: string,
265
+ ): Promise<{ bytes: Buffer; text: string }> {
266
+ let bytes: Buffer;
267
+ try {
268
+ bytes = await readFile(path);
269
+ } catch (error) {
270
+ fail(
271
+ `${label} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
272
+ artifactDir,
273
+ );
274
+ }
275
+ let text: string;
276
+ try {
277
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
278
+ } catch {
279
+ fail(`${label} is not well-formed UTF-8`, artifactDir);
280
+ }
281
+ return { bytes, text };
282
+ }
283
+
284
+ export interface ReadFusionCommittedResultOptions {
285
+ artifactDirAbs: string;
286
+ artifactDir: string;
287
+ runId: string;
288
+ workflow: FusionWorkflowId;
289
+ }
290
+
291
+ /** Verify the manifest-bound Fusion commit before returning merged bytes. */
292
+ export async function readFusionCommittedResult(
293
+ options: ReadFusionCommittedResultOptions,
294
+ ): Promise<FusionRunResult> {
295
+ const manifestFile = await readUtf8(
296
+ join(options.artifactDirAbs, 'manifest.json'),
297
+ 'manifest.json',
298
+ options.artifactDir,
299
+ );
300
+ let manifestValue: unknown;
301
+ try {
302
+ manifestValue = parseJsonText(manifestFile.text);
303
+ } catch (error) {
304
+ fail(
305
+ `manifest.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
306
+ options.artifactDir,
307
+ );
308
+ }
309
+ if (!isRecord(manifestValue)) fail('manifest.json must be an object', options.artifactDir);
310
+ if (manifestValue['schema_version'] !== FUSION_MANIFEST_SCHEMA_VERSION)
311
+ fail('manifest schema version mismatch', options.artifactDir);
312
+ if (manifestValue['run_id'] !== options.runId || manifestValue['workflow'] !== options.workflow)
313
+ fail('manifest identity mismatch', options.artifactDir);
314
+ if (manifestValue['state'] !== 'completed')
315
+ fail('manifest is not committed', options.artifactDir);
316
+ const artifacts = manifestValue['artifacts'];
317
+ if (!isRecord(artifacts)) fail('manifest artifacts map is invalid', options.artifactDir);
318
+ const manifestMerged = artifactRef(
319
+ artifacts['merged.md'],
320
+ 'manifest artifacts merged.md',
321
+ options.artifactDir,
322
+ );
323
+ const manifestResult = artifactRef(
324
+ artifacts['result.json'],
325
+ 'manifest artifacts result.json',
326
+ options.artifactDir,
327
+ );
328
+ if (manifestMerged.path !== 'merged.md' || manifestResult.path !== 'result.json')
329
+ fail('manifest fixed artifact paths are invalid', options.artifactDir);
330
+
331
+ const resultFile = await readUtf8(
332
+ join(options.artifactDirAbs, 'result.json'),
333
+ 'result.json',
334
+ options.artifactDir,
335
+ );
336
+ if (
337
+ resultFile.bytes.length !== manifestResult.byte_length ||
338
+ sha256Buffer(resultFile.bytes) !== manifestResult.sha256
339
+ ) {
340
+ fail('result.json does not match its manifest hash and length', options.artifactDir);
341
+ }
342
+ let resultValue: unknown;
343
+ try {
344
+ resultValue = parseJsonText(resultFile.text);
345
+ } catch (error) {
346
+ fail(
347
+ `result.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
348
+ options.artifactDir,
349
+ );
350
+ }
351
+ if (!isRecord(resultValue)) fail('result.json must be an object', options.artifactDir);
352
+ assertOnlyKeys(
353
+ resultValue,
354
+ ['schema_version', 'run_id', 'merged', 'details'],
355
+ 'result.json',
356
+ options.artifactDir,
357
+ );
358
+ if (
359
+ resultValue['schema_version'] !== FUSION_COMMITTED_RESULT_SCHEMA_VERSION ||
360
+ resultValue['run_id'] !== options.runId
361
+ ) {
362
+ fail('result.json identity mismatch', options.artifactDir);
363
+ }
364
+ const committedMerged = artifactRef(
365
+ resultValue['merged'],
366
+ 'result.json merged',
367
+ options.artifactDir,
368
+ );
369
+ if (!sameRef(committedMerged, manifestMerged))
370
+ fail('result.json merged reference does not match manifest', options.artifactDir);
371
+ const details = resultDetails(resultValue['details'], options);
372
+
373
+ const mergedFile = await readUtf8(
374
+ join(options.artifactDirAbs, 'merged.md'),
375
+ 'merged.md',
376
+ options.artifactDir,
377
+ );
378
+ if (
379
+ mergedFile.bytes.length !== committedMerged.byte_length ||
380
+ sha256Buffer(mergedFile.bytes) !== committedMerged.sha256
381
+ ) {
382
+ fail('merged.md does not match its committed hash and length', options.artifactDir);
383
+ }
384
+ return { mergedText: mergedFile.text, details };
385
+ }
@@ -18,6 +18,8 @@ export const FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION =
18
18
  'pi-background-tasks.fusion-validation-candidate.v1';
19
19
  export const FUSION_LEGACY_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v4';
20
20
  export const FUSION_RESULT_SCHEMA_VERSION = 'pi-background-tasks.fusion-result.v5';
21
+ export const FUSION_COMMITTED_RESULT_SCHEMA_VERSION =
22
+ 'pi-background-tasks.fusion-committed-result.v1';
21
23
  export const FUSION_LEGACY_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v3';
22
24
  export const FUSION_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.fusion-manifest.v4';
23
25
  export const FUSION_CONTEXT_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.fusion-context-ledger.v2';
@@ -553,6 +555,13 @@ export interface FusionResultBudgetDetails {
553
555
  calibration_warnings: readonly FusionCalibrationViolation[];
554
556
  }
555
557
 
558
+ export interface FusionCommittedResultV1 {
559
+ schema_version: typeof FUSION_COMMITTED_RESULT_SCHEMA_VERSION;
560
+ run_id: string;
561
+ merged: FusionArtifactRef;
562
+ details: FusionResultDetails;
563
+ }
564
+
556
565
  export interface FusionResultDetails {
557
566
  schema_version: typeof FUSION_RESULT_SCHEMA_VERSION;
558
567
  run_id: string;
@@ -28,6 +28,7 @@ import {
28
28
  type KillKind,
29
29
  type StartAttestedPiTaskOptions,
30
30
  type StartDelegateTaskOptions,
31
+ type StartManagedTaskOptions,
31
32
  type StartTaskOptions,
32
33
  type TaskContextUsage,
33
34
  type TaskStatus,
@@ -740,11 +741,13 @@ export class BackgroundTaskRegistry {
740
741
  this.killProcess = options.killProcess ?? process.kill.bind(process);
741
742
  this.platform = options.platform ?? process.platform;
742
743
  this.env = options.env ?? process.env;
743
- this.killTree = options.killTree ?? ((pid, phase, signal) => {
744
- const taskkillOptions: WindowsTaskkillOptions =
745
- signal === undefined ? { env: this.env } : { env: this.env, signal };
746
- return runWindowsTaskkill(pid, phase, taskkillOptions);
747
- });
744
+ this.killTree =
745
+ options.killTree ??
746
+ ((pid, phase, signal) => {
747
+ const taskkillOptions: WindowsTaskkillOptions =
748
+ signal === undefined ? { env: this.env } : { env: this.env, signal };
749
+ return runWindowsTaskkill(pid, phase, taskkillOptions);
750
+ });
748
751
  this.makeTaskIdFn = options.makeTaskId ?? defaultTaskId;
749
752
  this.now = options.now ?? Date.now;
750
753
  this.maxOutputBytes = options.maxOutputBytes ?? MAX_OUTPUT_BYTES;
@@ -869,7 +872,8 @@ export class BackgroundTaskRegistry {
869
872
  let commandToSpawn = normalizedCommand;
870
873
  if (piTelemetryRequested) {
871
874
  if (baseInvocation.dialect === 'posix') {
872
- if (piTelemetryLaunch === undefined) throw new Error('Pi telemetry launch spec was not resolved');
875
+ if (piTelemetryLaunch === undefined)
876
+ throw new Error('Pi telemetry launch spec was not resolved');
873
877
  const wrapperAbsPath = join(dir.abs, `${id}.pi-telemetry-wrapper.cjs`);
874
878
  await writeFile(
875
879
  wrapperAbsPath,
@@ -962,6 +966,128 @@ export class BackgroundTaskRegistry {
962
966
  }
963
967
  }
964
968
 
969
+ /**
970
+ * Track an in-process asynchronous workflow through the same durable task,
971
+ * notification, status, log, and cancellation surfaces as child processes.
972
+ * The supplied completion promise must own all workflow cleanup before it
973
+ * settles; terminal publication happens only after that settlement.
974
+ */
975
+ async startManagedTask(
976
+ ctx: BackgroundTaskContext,
977
+ request: StartManagedTaskOptions,
978
+ ): Promise<BgTask> {
979
+ if (this.shuttingDown)
980
+ throw new Error('Cannot start a managed background task while Pi is shutting down');
981
+ if (!/^[a-zA-Z0-9_.-]+$/u.test(request.id))
982
+ throw new Error(`Managed background task id is invalid: ${request.id}`);
983
+ if (this.tasks.has(request.id))
984
+ throw new Error(`Background task id already exists: ${request.id}`);
985
+
986
+ const dir = await this.ensureRuntimeDir(ctx);
987
+ const outputAbsPath = join(dir.abs, `${request.id}.output`);
988
+ const metadataAbsPath = join(dir.abs, `${request.id}.json`);
989
+ const outputPath = join(dir.display, `${request.id}.output`);
990
+ const task: BgTask = {
991
+ id: request.id,
992
+ name: normalizeTaskName(request.name) ?? 'Managed background task',
993
+ command: request.command,
994
+ description: request.description,
995
+ status: 'running',
996
+ outputPath,
997
+ outputAbsPath,
998
+ metadataAbsPath,
999
+ cwd: ctx.cwd,
1000
+ startTime: this.now(),
1001
+ exitCode: undefined,
1002
+ pid: undefined,
1003
+ bytesWritten: 0,
1004
+ isAgent: request.isAgent,
1005
+ notified: false,
1006
+ notifyOnCompletion: request.notifyOnCompletion,
1007
+ triggerOnCompletion: request.triggerOnCompletion,
1008
+ fusion: request.fusion,
1009
+ managedCancel: request.cancel,
1010
+ managedStopWaitMs: request.stopWaitMs,
1011
+ terminalPublicationGate: request.terminalPublicationGate,
1012
+ waiters: [],
1013
+ };
1014
+ this.tasks.set(task.id, task);
1015
+ const stream = createWriteStream(outputAbsPath, { flags: 'a', encoding: 'utf8' });
1016
+ task.stream = stream;
1017
+ stream.on('error', (error) => {
1018
+ task.error = `Output file write failed: ${error.message}`;
1019
+ if (task.status === 'running' && !task.managedCancelRequested) {
1020
+ task.managedCancelRequested = true;
1021
+ try {
1022
+ request.cancel();
1023
+ } catch (cancelError) {
1024
+ task.error = `${task.error}; cancellation failed: ${BackgroundTaskRegistry.errorMessage(cancelError)}`;
1025
+ }
1026
+ }
1027
+ });
1028
+
1029
+ try {
1030
+ await this.writeMetadata(task);
1031
+ this.onChange();
1032
+ } catch (error) {
1033
+ this.tasks.delete(task.id);
1034
+ if (!stream.destroyed) stream.destroy();
1035
+ try {
1036
+ request.cancel();
1037
+ } catch (cancelError) {
1038
+ this.logger.error(
1039
+ `[background-tasks] managed task cancellation after metadata failure also failed for ${task.id}:`,
1040
+ cancelError,
1041
+ );
1042
+ }
1043
+ throw new Error(
1044
+ `Failed to register managed background task: ${BackgroundTaskRegistry.errorMessage(error)}`,
1045
+ );
1046
+ }
1047
+
1048
+ void request.completion
1049
+ .then(
1050
+ () => this.finalizeTask(task, 'completed', 0),
1051
+ (error: unknown) => {
1052
+ const message = BackgroundTaskRegistry.errorMessage(error);
1053
+ const killed = task.killKind === 'user' || task.killKind === 'shutdown';
1054
+ return this.finalizeTask(task, killed ? 'killed' : 'failed', null, undefined, message);
1055
+ },
1056
+ )
1057
+ .catch((error: unknown) => {
1058
+ this.logger.error(
1059
+ `[background-tasks] managed task finalization failed for ${task.id}:`,
1060
+ error,
1061
+ );
1062
+ });
1063
+ return task;
1064
+ }
1065
+
1066
+ async updateManagedTask(task: BgTask, state: string, line?: string): Promise<void> {
1067
+ if (task.status !== 'running' || task.fusion === undefined) return;
1068
+ task.fusion.state = state;
1069
+ if (line !== undefined && line.length > 0) this.writeNotice(task, `${line}\n`);
1070
+ await this.writeMetadata(task);
1071
+ this.onChange();
1072
+ }
1073
+
1074
+ /** Claim deferred Fusion usage exactly once before returning it from bg_result. */
1075
+ async claimFusionUsage(task: BgTask): Promise<boolean> {
1076
+ if (task.fusion === undefined) throw new Error(`Task ${task.id} is not a Fusion task`);
1077
+ let claimed = false;
1078
+ const write = async () => {
1079
+ if (!task.fusion || task.fusion.usageDelivered) return;
1080
+ task.fusion.usageDelivered = true;
1081
+ await writeJsonAtomic(task.metadataAbsPath, snapshot(task));
1082
+ claimed = true;
1083
+ };
1084
+ const previous = task.metadataWriteChain ?? Promise.resolve();
1085
+ const next = previous.then(write, write);
1086
+ task.metadataWriteChain = next.catch(() => undefined);
1087
+ await next;
1088
+ return claimed;
1089
+ }
1090
+
965
1091
  /**
966
1092
  * Start a prepared delegate child.
967
1093
  *
@@ -1420,15 +1546,16 @@ export class BackgroundTaskRegistry {
1420
1546
  task.killKind = kind;
1421
1547
  if (reason) task.error = reason;
1422
1548
  this.requestKill(task, 'SIGTERM');
1549
+ const stopWaitMs = task.managedStopWaitMs ?? this.stopWaitMs;
1423
1550
  const stopped =
1424
- this.platform === 'win32'
1425
- ? await this.waitForEndOrWindowsForceFailure(task, this.stopWaitMs)
1426
- : await this.waitForEnd(task, this.stopWaitMs);
1551
+ this.platform === 'win32' && task.managedCancel === undefined
1552
+ ? await this.waitForEndOrWindowsForceFailure(task, stopWaitMs)
1553
+ : await this.waitForEnd(task, stopWaitMs);
1427
1554
  const forceFailure = this.windowsKillStates.get(task)?.forceFailure;
1428
1555
  if (forceFailure !== undefined) throw forceFailure;
1429
1556
  if (!stopped) {
1430
1557
  throw new Error(
1431
- `Task ${task.id} did not exit within ${formatDuration(this.stopWaitMs)} after SIGTERM/SIGKILL`,
1558
+ `Task ${task.id} did not exit within ${formatDuration(stopWaitMs)} after cancellation`,
1432
1559
  );
1433
1560
  }
1434
1561
  return task;
@@ -1880,7 +2007,10 @@ export class BackgroundTaskRegistry {
1880
2007
  const rejectForceReady = rejectForce;
1881
2008
  state.forcePromise = forcePromise;
1882
2009
  void forcePromise.catch((error: unknown) => {
1883
- this.logger.error(`[background-tasks] Windows force tree termination failed for ${task.id}:`, error);
2010
+ this.logger.error(
2011
+ `[background-tasks] Windows force tree termination failed for ${task.id}:`,
2012
+ error,
2013
+ );
1884
2014
  });
1885
2015
 
1886
2016
  this.clearKillEscalationTimer(task);
@@ -1922,7 +2052,11 @@ export class BackgroundTaskRegistry {
1922
2052
  resolveForceReady();
1923
2053
  return;
1924
2054
  }
1925
- const failure = this.makeWindowsForceFailure(task, pid, BackgroundTaskRegistry.errorMessage(error));
2055
+ const failure = this.makeWindowsForceFailure(
2056
+ task,
2057
+ pid,
2058
+ BackgroundTaskRegistry.errorMessage(error),
2059
+ );
1926
2060
  this.recordWindowsForceFailure(task, failure);
1927
2061
  rejectForceReady(failure);
1928
2062
  },
@@ -1965,6 +2099,19 @@ export class BackgroundTaskRegistry {
1965
2099
  if (task.status !== 'running') {
1966
2100
  throw new Error(`Task ${task.id} is ${task.status}, not running`);
1967
2101
  }
2102
+ if (task.managedCancel !== undefined) {
2103
+ if (task.managedCancelRequested) return;
2104
+ task.managedCancelRequested = true;
2105
+ try {
2106
+ task.managedCancel();
2107
+ } catch (error) {
2108
+ throw new Error(
2109
+ `Could not cancel managed task ${task.id}: ${BackgroundTaskRegistry.errorMessage(error)}`,
2110
+ );
2111
+ }
2112
+ task.killSignalSent = true;
2113
+ return;
2114
+ }
1968
2115
  if (!task.child) {
1969
2116
  throw new Error(`Task ${task.id} has no child process handle`);
1970
2117
  }
@@ -2145,6 +2292,12 @@ export class BackgroundTaskRegistry {
2145
2292
  task.exitCode === undefined ? '' : `\n <exit-code>${String(task.exitCode)}</exit-code>`;
2146
2293
  const error = task.error ? `\n <error>${escapeXml(task.error)}</error>` : '';
2147
2294
  const taskName = taskDisplayName(task);
2295
+ const guidance =
2296
+ task.fusion === undefined
2297
+ ? 'Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.'
2298
+ : task.status === 'completed'
2299
+ ? `Fusion result is durably committed at ${task.fusion.artifactDir}. Call bg_result({taskId:${JSON.stringify(task.id)}}) once to retrieve it; do not poll.`
2300
+ : `Fusion ended ${task.status}. Inspect the preserved artifacts at ${task.fusion.artifactDir}; do not poll.`;
2148
2301
  const content = [
2149
2302
  '<background-task-notification>',
2150
2303
  ` <task-id>${task.id}</task-id>`,
@@ -2154,7 +2307,7 @@ export class BackgroundTaskRegistry {
2154
2307
  error,
2155
2308
  ` <output-file>${escapeXml(task.outputPath)}</output-file>`,
2156
2309
  ` <summary>${escapeXml(`Background task ${JSON.stringify(taskName)} ${task.status}`)}</summary>`,
2157
- ' <guidance>Terminal state and output metadata are durable. Do not call bg_status to reconfirm; use bg_logs only if output is needed.</guidance>',
2310
+ ` <guidance>${escapeXml(guidance)}</guidance>`,
2158
2311
  '</background-task-notification>',
2159
2312
  ]
2160
2313
  .filter(Boolean)
@@ -2250,13 +2403,27 @@ export class BackgroundTaskRegistry {
2250
2403
  for (const waiter of task.waiters.splice(0)) waiter();
2251
2404
  this.onChange();
2252
2405
  this.publishTerminal(task);
2253
- try {
2254
- this.notifyCompletion(task);
2255
- } catch (notificationError) {
2256
- this.logger.error(
2257
- `[background-tasks] notification failed for ${task.id}:`,
2258
- notificationError,
2259
- );
2406
+ let deliveryGateReady = true;
2407
+ if (task.terminalPublicationGate !== undefined) {
2408
+ try {
2409
+ await task.terminalPublicationGate;
2410
+ } catch (error) {
2411
+ deliveryGateReady = false;
2412
+ this.logger.error(
2413
+ `[background-tasks] completion delivery gate failed for ${task.id}:`,
2414
+ error,
2415
+ );
2416
+ }
2417
+ }
2418
+ if (deliveryGateReady) {
2419
+ try {
2420
+ this.notifyCompletion(task);
2421
+ } catch (notificationError) {
2422
+ this.logger.error(
2423
+ `[background-tasks] notification failed for ${task.id}:`,
2424
+ notificationError,
2425
+ );
2426
+ }
2260
2427
  }
2261
2428
  try {
2262
2429
  await this.writeMetadata(task);