pi-background-tasks 2.1.3 → 2.3.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 (44) hide show
  1. package/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
  2. package/PUBLISHING.md +2 -0
  3. package/README.md +16 -9
  4. package/TESTING.md +15 -10
  5. package/TEST_PLAN.md +12 -11
  6. package/THIRD_PARTY_NOTICES.md +30 -0
  7. package/docs/INDEX.md +8 -4
  8. package/docs/choose-a-workflow.md +5 -2
  9. package/docs/commands/claude-cache.md +50 -0
  10. package/docs/getting-started.md +3 -0
  11. package/docs/manifest.json +84 -19
  12. package/docs/operations/configuration.md +15 -1
  13. package/docs/operations/releasing.md +6 -3
  14. package/docs/read-before-edit.md +4 -1
  15. package/docs/reference/runtime-contracts.md +72 -71
  16. package/docs/subsystems/anthropic-attribution.md +63 -0
  17. package/docs/subsystems/attested-pi-runs.md +2 -2
  18. package/docs/subsystems/child-launch-durability-and-safety.md +3 -3
  19. package/docs/subsystems/delegation.md +12 -4
  20. package/docs/subsystems/docs-freshness-gate.md +6 -6
  21. package/docs/subsystems/fusion.md +6 -2
  22. package/docs/tools/bg_delegate.md +21 -9
  23. package/docs/tools/bg_result.md +8 -1
  24. package/docs/tools/bg_run.md +5 -0
  25. package/extensions/anthropic-attribution.ts +1 -0
  26. package/package.json +4 -2
  27. package/src/core/anthropic-attribution-path.ts +26 -0
  28. package/src/core/{fusion/anthropic-attribution.ts → anthropic-attribution.ts} +61 -8
  29. package/src/core/attested-pi-run.ts +10 -1
  30. package/src/core/common.ts +2 -1
  31. package/src/core/delegate/artifacts.ts +4 -0
  32. package/src/core/delegate/launch.ts +44 -14
  33. package/src/core/delegate/runner.ts +24 -0
  34. package/src/core/delegate/seed.ts +12 -0
  35. package/src/core/delegate/types.ts +10 -2
  36. package/src/core/fusion/artifacts.ts +265 -3
  37. package/src/core/fusion/config.ts +1 -1
  38. package/src/core/fusion/orchestrator.ts +47 -57
  39. package/src/core/fusion/pi-child.ts +7 -124
  40. package/src/core/fusion/result-package.ts +550 -3
  41. package/src/core/fusion/types.ts +87 -0
  42. package/src/core/registry.ts +4 -1
  43. package/src/delegate-child-extension.ts +9 -2
  44. package/src/delegate-extension.ts +119 -9
@@ -17,6 +17,7 @@ import {
17
17
  DELEGATE_TOOL_NAME,
18
18
  DelegateError,
19
19
  type DelegateCapability,
20
+ type DelegateExtensionMode,
20
21
  type DelegateLimits,
21
22
  type DelegatePinnedRoute,
22
23
  type DelegateRoute,
@@ -87,7 +88,7 @@ export function resolveDelegateRoute(input: DelegateRouteResolutionInput): Deleg
87
88
  childCreated: false,
88
89
  remediation: [
89
90
  'Name a provider/model pair that appears in the current model registry.',
90
- 'Omit the route argument to use the parent session\'s current model.',
91
+ "Omit the route argument to use the parent session's current model.",
91
92
  ],
92
93
  },
93
94
  );
@@ -239,12 +240,23 @@ export function delegateToolsFor(capability: DelegateCapability): readonly strin
239
240
  });
240
241
  }
241
242
 
243
+ function assertDelegateExtensionMode(mode: DelegateExtensionMode): void {
244
+ if (mode === 'isolated' || mode === 'ambient') return;
245
+ throw new DelegateError(`bg_delegate extension mode ${String(mode)} is not supported`, {
246
+ code: 'invalid_arguments',
247
+ childCreated: false,
248
+ remediation: ['Use extensionMode "isolated" or "ambient".'],
249
+ });
250
+ }
251
+
242
252
  export interface DelegateChildArgvInput {
243
253
  route: DelegatePinnedRoute;
244
254
  capability: DelegateCapability;
255
+ extensionMode: DelegateExtensionMode;
245
256
  childSessionId: string;
246
257
  childSessionDir: string;
247
258
  childExtensionPath: string;
259
+ attributionExtensionPath?: string | undefined;
248
260
  systemPrompt: string;
249
261
  }
250
262
 
@@ -252,11 +264,15 @@ export interface DelegateChildArgvInput {
252
264
  * Build the child argv.
253
265
  *
254
266
  * The child gets its own `--session-id` and a task-owned `--session-dir`, so it
255
- * is structurally incapable of opening or mutating the parent session. Discovery
256
- * of ambient extensions, skills, prompt templates, themes, and context files is
257
- * disabled; only the package's own guard extension is loaded explicitly.
267
+ * is structurally incapable of opening or mutating the parent session. Skills,
268
+ * prompt templates, themes, and context files are always disabled. Extension
269
+ * discovery is disabled in isolated mode and deliberately enabled in ambient
270
+ * mode; ambient extensions execute arbitrary code and are not sandboxed by the
271
+ * model-visible tool allowlist. The package guard is always explicit; Anthropic
272
+ * routes first load the package attribution/sanitization extension.
258
273
  */
259
274
  export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[] {
275
+ assertDelegateExtensionMode(input.extensionMode);
260
276
  const tools = delegateToolsFor(input.capability);
261
277
  for (const forbidden of DELEGATE_FORBIDDEN_TOOLS) {
262
278
  if (tools.includes(forbidden)) {
@@ -266,6 +282,20 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
266
282
  );
267
283
  }
268
284
  }
285
+ const extensionPaths =
286
+ input.route.provider === 'anthropic'
287
+ ? [
288
+ input.attributionExtensionPath ??
289
+ (() => {
290
+ throw new DelegateError(
291
+ 'Anthropic delegate launch requires the package attribution extension',
292
+ { code: 'delegate_isolation_unsupported', childCreated: false },
293
+ );
294
+ })(),
295
+ input.childExtensionPath,
296
+ ]
297
+ : [input.childExtensionPath];
298
+
269
299
  return [
270
300
  '--mode',
271
301
  'text',
@@ -279,13 +309,12 @@ export function buildDelegateChildArgv(input: DelegateChildArgvInput): string[]
279
309
  tools.join(','),
280
310
  '--exclude-tools',
281
311
  DELEGATE_FORBIDDEN_TOOLS.join(','),
282
- '--no-extensions',
312
+ ...(input.extensionMode === 'isolated' ? ['--no-extensions'] : []),
283
313
  '--no-skills',
284
314
  '--no-prompt-templates',
285
315
  '--no-themes',
286
316
  '--no-context-files',
287
- '--extension',
288
- input.childExtensionPath,
317
+ ...extensionPaths.flatMap((path) => ['--extension', path]),
289
318
  '--provider',
290
319
  input.route.provider,
291
320
  '--model',
@@ -362,6 +391,7 @@ export interface DelegatePreflightInput {
362
391
  toolCallId: string | undefined;
363
392
  prompt: string;
364
393
  capability: DelegateCapability;
394
+ extensionMode: DelegateExtensionMode;
365
395
  route: DelegatePinnedRoute;
366
396
  limitOverrides: DelegateLimitOverrides;
367
397
  hookEvidence: DelegateHookContractEvidence;
@@ -411,10 +441,9 @@ export function buildDelegateChildPrompt(seedSerialized: string, directive: stri
411
441
  * one of these can refuse, and none of them has created a process, a session, or
412
442
  * an artifact by the time it does.
413
443
  */
414
- export function preflightDelegateLaunch(
415
- input: DelegatePreflightInput,
416
- ): DelegatePreflightResult {
444
+ export function preflightDelegateLaunch(input: DelegatePreflightInput): DelegatePreflightResult {
417
445
  assertDelegateHookContract(input.hookEvidence);
446
+ assertDelegateExtensionMode(input.extensionMode);
418
447
  // Validates the capability and proves the tool set contains nothing forbidden.
419
448
  delegateToolsFor(input.capability);
420
449
  const limits = resolveDelegateLimits(input.route, input.limitOverrides);
@@ -427,10 +456,13 @@ export function preflightDelegateLaunch(
427
456
  toolCallId: input.toolCallId,
428
457
  directive: input.prompt,
429
458
  capability: input.capability,
459
+ extensionMode: input.extensionMode,
430
460
  route: input.route,
431
461
  limits,
432
462
  });
433
- const childSystemPrompt = buildDelegateChildSystemPrompt('the task seed in your first user message');
463
+ const childSystemPrompt = buildDelegateChildSystemPrompt(
464
+ 'the task seed in your first user message',
465
+ );
434
466
  const childPrompt = buildDelegateChildPrompt(seed.serialized, seed.seed.directive.text);
435
467
  const plan = planDelegateAdmission({
436
468
  route: input.route,
@@ -454,9 +486,7 @@ export function preflightDelegateLaunch(
454
486
  }
455
487
 
456
488
  /** Task-owned child session directory. Never the parent's session directory. */
457
- export async function ensureDelegateChildSessionDir(
458
- artifactDirAbs: string,
459
- ): Promise<string> {
489
+ export async function ensureDelegateChildSessionDir(artifactDirAbs: string): Promise<string> {
460
490
  const dir = join(artifactDirAbs, 'child-session');
461
491
  await mkdir(dir, { recursive: true, mode: 0o700 });
462
492
  return dir;
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { canonicalJson } from '../attested-pi-run.js';
4
4
  import { replaceFileDurable } from '../durable-fs.js';
5
+ import { resolveAnthropicAttributionExtensionPath } from '../anthropic-attribution-path.js';
5
6
  import { join } from 'node:path';
6
7
  import { DelegateArtifactStore, discardDelegateArtifactRoot } from './artifacts.js';
7
8
  import {
@@ -20,6 +21,7 @@ import { verifyDelegateResultPackage, type VerifiedDelegateResult } from './resu
20
21
  import {
21
22
  DelegateError,
22
23
  type DelegateAutoDeliverMode,
24
+ type DelegateExtensionMode,
23
25
  type DelegateResultPackageV1,
24
26
  } from './types.js';
25
27
  import type { DelegateTaskFacts, DelegateTaskOutcome } from '../common.js';
@@ -48,7 +50,9 @@ export interface PrepareDelegateLaunchInput extends DelegatePreflightInput {
48
50
  cwd: string;
49
51
  sessionId: string | undefined;
50
52
  autoDeliver: DelegateAutoDeliverMode;
53
+ extensionMode: DelegateExtensionMode;
51
54
  childExtensionPath?: string | undefined;
55
+ attributionExtensionPath?: string | undefined;
52
56
  env?: NodeJS.ProcessEnv | undefined;
53
57
  now?: (() => Date) | undefined;
54
58
  }
@@ -68,6 +72,22 @@ export async function prepareDelegateLaunch(
68
72
  // its child guard must refuse rather than spawn an unguarded child.
69
73
  const childExtensionPath =
70
74
  input.childExtensionPath ?? resolveDelegateChildExtensionPath();
75
+ let attributionExtensionPath: string | undefined;
76
+ if (input.route.provider === 'anthropic') {
77
+ try {
78
+ attributionExtensionPath =
79
+ input.attributionExtensionPath ?? resolveAnthropicAttributionExtensionPath();
80
+ } catch (error) {
81
+ throw new DelegateError(
82
+ `Anthropic delegate attribution extension could not be resolved: ${error instanceof Error ? error.message : String(error)}`,
83
+ {
84
+ code: 'delegate_isolation_unsupported',
85
+ childCreated: false,
86
+ remediation: ['Reinstall the package; Anthropic delegates require attribution.'],
87
+ },
88
+ );
89
+ }
90
+ }
71
91
 
72
92
  const preflight = preflightDelegateLaunch(input);
73
93
 
@@ -78,6 +98,7 @@ export async function prepareDelegateLaunch(
78
98
  sessionId: input.sessionId,
79
99
  childSessionId: preflight.childSessionId,
80
100
  childSessionDir: '',
101
+ extensionMode: input.extensionMode,
81
102
  route: input.route,
82
103
  limits: preflight.limits,
83
104
  seedSha256: preflight.seed.sha256,
@@ -107,9 +128,11 @@ export async function prepareDelegateLaunch(
107
128
  const argv = buildDelegateChildArgv({
108
129
  route: input.route,
109
130
  capability: input.capability,
131
+ extensionMode: input.extensionMode,
110
132
  childSessionId: preflight.childSessionId,
111
133
  childSessionDir: childSessionDirAbs,
112
134
  childExtensionPath,
135
+ attributionExtensionPath,
113
136
  systemPrompt: preflight.childSystemPrompt,
114
137
  });
115
138
  const env = delegateChildEnv(
@@ -138,6 +161,7 @@ export async function prepareDelegateLaunch(
138
161
  family: preflight.plan.route.family,
139
162
  rate_source: preflight.plan.route.rate_source,
140
163
  },
164
+ extensionMode: input.extensionMode,
141
165
  autoDeliver: input.autoDeliver,
142
166
  };
143
167
  const stdinBytes = Buffer.from(preflight.childPrompt, 'utf8');
@@ -22,6 +22,7 @@ import {
22
22
  type DelegateContextPolicyDescriptor,
23
23
  type DelegateConversationProjection,
24
24
  type DelegateLedgerV1,
25
+ type DelegateExtensionMode,
25
26
  type DelegateLimits,
26
27
  type DelegatePinnedRoute,
27
28
  type DelegateSeedV1,
@@ -34,6 +35,7 @@ export interface BuildDelegateSeedOptions {
34
35
  toolCallId: string | undefined;
35
36
  directive: string;
36
37
  capability: DelegateCapability;
38
+ extensionMode: DelegateExtensionMode;
37
39
  route: DelegatePinnedRoute;
38
40
  limits: DelegateLimits;
39
41
  }
@@ -167,6 +169,7 @@ export function buildDelegateSeed(
167
169
  launch_nonce: options.launchNonce,
168
170
  cwd: ctx.cwd,
169
171
  capability: options.capability,
172
+ extension_mode: options.extensionMode,
170
173
  route: options.route,
171
174
  parent_system_prompt: ctx.getSystemPrompt(),
172
175
  parent_leaf_id: snapshot.leafId,
@@ -296,6 +299,14 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
296
299
  taskId,
297
300
  });
298
301
  }
302
+ const extensionMode = requireString(record, 'extension_mode', taskId);
303
+ if (extensionMode !== 'isolated' && extensionMode !== 'ambient') {
304
+ throw new DelegateError(`delegate seed extension mode ${extensionMode} is not recognised`, {
305
+ code: 'seed_hash_mismatch',
306
+ childCreated: true,
307
+ taskId,
308
+ });
309
+ }
299
310
  const routeRecord = requireRecord(record, 'route', taskId);
300
311
  const routeOrigin = requireString(routeRecord, 'origin', taskId);
301
312
  if (routeOrigin !== 'parent_current' && routeOrigin !== 'explicit') {
@@ -378,6 +389,7 @@ function rebuildSeed(record: Record<PropertyKey, unknown>, taskId: string): Dele
378
389
  launch_nonce: requireString(record, 'launch_nonce', taskId),
379
390
  cwd: requireString(record, 'cwd', taskId),
380
391
  capability,
392
+ extension_mode: extensionMode,
381
393
  route,
382
394
  parent_system_prompt: requireString(record, 'parent_system_prompt', taskId),
383
395
  parent_leaf_id: parentLeafId,
@@ -12,14 +12,14 @@ import type {
12
12
  ProjectionEntry,
13
13
  } from '../context/visible-conversation-v2.js';
14
14
 
15
- export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.v1' as const;
15
+ export const DELEGATE_SEED_SCHEMA_VERSION = 'pi-background-tasks.delegate-seed.v2' as const;
16
16
  export const DELEGATE_LEDGER_SCHEMA_VERSION = 'pi-background-tasks.delegate-ledger.v1' as const;
17
17
  export const DELEGATE_RESULT_PACKAGE_SCHEMA_VERSION =
18
18
  'pi-background-tasks.delegate-result.v1' as const;
19
19
  export const DELEGATE_RECEIPT_SCHEMA_VERSION = 'pi-background-tasks.delegate-receipt.v1' as const;
20
20
  export const DELEGATE_BUDGET_PLAN_SCHEMA_VERSION =
21
21
  'pi-background-tasks.delegate-budget-plan.v2' as const;
22
- export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.v1' as const;
22
+ export const DELEGATE_MANIFEST_SCHEMA_VERSION = 'pi-background-tasks.delegate-manifest.v2' as const;
23
23
 
24
24
  /**
25
25
  * Delegate's own context policy id. It shares the frozen
@@ -35,6 +35,13 @@ export const DELEGATE_RESULT_TOOL_NAME = 'bg_result';
35
35
  export const DELEGATE_CAPABILITIES = ['inspect'] as const;
36
36
  export type DelegateCapability = (typeof DELEGATE_CAPABILITIES)[number];
37
37
 
38
+ /**
39
+ * Controls only Pi's ambient extension discovery for delegate children.
40
+ * Tool and project-resource restrictions remain independently enforced.
41
+ */
42
+ export const DELEGATE_EXTENSION_MODES = ['isolated', 'ambient'] as const;
43
+ export type DelegateExtensionMode = (typeof DELEGATE_EXTENSION_MODES)[number];
44
+
38
45
  export const DELEGATE_AUTO_DELIVER_MODES = ['never', 'when_small', 'always'] as const;
39
46
  export type DelegateAutoDeliverMode = (typeof DELEGATE_AUTO_DELIVER_MODES)[number];
40
47
 
@@ -110,6 +117,7 @@ export interface DelegateSeedV1 {
110
117
  launch_nonce: string;
111
118
  cwd: string;
112
119
  capability: DelegateCapability;
120
+ extension_mode: DelegateExtensionMode;
113
121
  route: DelegatePinnedRoute;
114
122
  parent_system_prompt: string;
115
123
  parent_leaf_id: string | null;
@@ -7,12 +7,18 @@ import { replaceFileDurable } from '../durable-fs.js';
7
7
  import {
8
8
  EMPTY_FUSION_USAGE,
9
9
  FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
10
+ FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
10
11
  FUSION_MANIFEST_SCHEMA_VERSION,
11
12
  FUSION_VALIDATE_CANDIDATE_CONTRACT_EVENT_SCHEMA_VERSION,
12
13
  FusionError,
13
14
  cloneFusionUsage,
14
15
  type FusionArtifactManifest,
15
16
  type FusionArtifactRef,
17
+ type FusionFailureArtifactClassification,
18
+ type FusionFailureAttemptMetadata,
19
+ type FusionFailureEvidenceArtifact,
20
+ type FusionFailureSummaryV1,
21
+ type FusionRunProgress,
16
22
  type FusionAttemptArtifactRecord,
17
23
  type FusionBudgetPlanV1,
18
24
  type FusionCalibrationViolation,
@@ -318,6 +324,210 @@ function artifactRefSha256Hex(value: string): string {
318
324
  return hex;
319
325
  }
320
326
 
327
+ /** A manifest artifact reference is always one safe basename below its run directory. */
328
+ export function assertFusionArtifactBasename(name: string): string {
329
+ if (
330
+ name.length === 0 ||
331
+ name !== basename(name) ||
332
+ name.includes('/') ||
333
+ name.includes('\\') ||
334
+ name === '.' ||
335
+ name === '..' ||
336
+ Buffer.byteLength(name, 'utf8') > 255
337
+ ) {
338
+ throw errorForArtifact(`invalid fusion artifact name: ${name}`);
339
+ }
340
+ return name;
341
+ }
342
+
343
+ export const FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES = 1024;
344
+ export const FUSION_FAILURE_SUMMARY_ATTEMPT_CAP = 12;
345
+ export const FUSION_FAILURE_SUMMARY_EVIDENCE_CAP = 24;
346
+ export const FUSION_FAILURE_SUMMARY_MAX_BYTES = 32 * 1024;
347
+
348
+ function compareArtifactText(left: string, right: string): number {
349
+ return left < right ? -1 : left > right ? 1 : 0;
350
+ }
351
+
352
+ interface FusionProgressManifest {
353
+ state: FusionState;
354
+ artifacts: Readonly<Record<string, FusionArtifactRef>>;
355
+ attempts: readonly {
356
+ stage: FusionStage;
357
+ slot?: 1 | 2 | 3 | undefined;
358
+ status: 'completed' | 'failed' | 'cancelled';
359
+ child_created: boolean;
360
+ }[];
361
+ usage: FusionUsage;
362
+ }
363
+
364
+ function failureStageProgress(
365
+ manifest: FusionProgressManifest,
366
+ stage: FusionStage,
367
+ ): FusionRunProgress['candidates'] {
368
+ const attempts = manifest.attempts.filter((attempt) => attempt.stage === stage);
369
+ const created = attempts.filter((attempt) => attempt.child_created).length;
370
+ const completed = attempts.filter(
371
+ (attempt) => attempt.child_created && attempt.status === 'completed',
372
+ ).length;
373
+ const failed = attempts.filter(
374
+ (attempt) => attempt.child_created && attempt.status === 'failed',
375
+ ).length;
376
+ const cancelled = attempts.filter(
377
+ (attempt) => attempt.child_created && attempt.status === 'cancelled',
378
+ ).length;
379
+ const completedByState =
380
+ stage === 'candidate'
381
+ ? completed >= 3
382
+ : stage === 'evaluation'
383
+ ? manifest.artifacts['evaluation.json'] !== undefined ||
384
+ manifest.state === 'evaluation_complete' ||
385
+ manifest.state === 'merging' ||
386
+ manifest.state === 'completed'
387
+ : manifest.artifacts['merged.md'] !== undefined || manifest.state === 'completed';
388
+ const progress: FusionRunProgress['candidates'] = {
389
+ status: completedByState ? 'completed' : created === 0 ? 'not_started' : 'incomplete',
390
+ attempts_recorded: attempts.length,
391
+ children_created: created,
392
+ children_completed: completed,
393
+ children_failed: failed,
394
+ children_cancelled: cancelled,
395
+ };
396
+ if (stage === 'candidate') {
397
+ const createdSlots = new Set(
398
+ attempts.flatMap((attempt) =>
399
+ attempt.child_created && attempt.slot !== undefined ? [attempt.slot] : [],
400
+ ),
401
+ );
402
+ progress.not_started_slots = 3 - createdSlots.size;
403
+ }
404
+ return progress;
405
+ }
406
+
407
+ /** Derive terminal progress solely from the durable manifest. */
408
+ export function buildFusionRunProgress(manifest: FusionProgressManifest): FusionRunProgress {
409
+ return {
410
+ manifest_state: manifest.state,
411
+ candidates: failureStageProgress(manifest, 'candidate'),
412
+ evaluation: failureStageProgress(manifest, 'evaluation'),
413
+ merge: failureStageProgress(manifest, 'merge'),
414
+ usage_so_far: cloneFusionUsage(manifest.usage),
415
+ };
416
+ }
417
+
418
+ function terminalMessageMetadata(message: string): FusionFailureSummaryV1['failure']['message'] {
419
+ const bytes = Buffer.from(message, 'utf8');
420
+ return {
421
+ byte_length: bytes.length,
422
+ sha256: sha256Buffer(bytes),
423
+ ...(bytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
424
+ ? { inline_message: message }
425
+ : { omission_reason: 'exceeds_inline_message_bytes_cap' as const }),
426
+ };
427
+ }
428
+
429
+ function failureArtifactClassification(
430
+ name: string,
431
+ ref: FusionArtifactRef,
432
+ manifest: FusionArtifactManifest,
433
+ ): FusionFailureArtifactClassification {
434
+ for (const attempt of manifest.attempts) {
435
+ if (attempt.response_path === name) {
436
+ return ref.byte_length === 0 && attempt.status !== 'completed'
437
+ ? 'empty_rejected_output'
438
+ : 'complete_stage_output';
439
+ }
440
+ if (attempt.partial_response_path === name) return 'partial_stage_output';
441
+ if (attempt.output_recovery?.original_response_path === name) return 'oversized_original';
442
+ }
443
+ return 'evidence_only';
444
+ }
445
+
446
+ function failureAttemptMetadata(manifest: FusionArtifactManifest): readonly FusionFailureAttemptMetadata[] {
447
+ return manifest.attempts
448
+ .map((attempt) => ({
449
+ stage: attempt.stage,
450
+ ...(attempt.slot === undefined ? {} : { slot: attempt.slot }),
451
+ attempt: attempt.attempt,
452
+ status: attempt.status,
453
+ child_created: attempt.child_created,
454
+ }))
455
+ .sort((left, right) =>
456
+ compareArtifactText(left.stage, right.stage) ||
457
+ (left.slot ?? 0) - (right.slot ?? 0) ||
458
+ left.attempt - right.attempt,
459
+ );
460
+ }
461
+
462
+ export function buildFusionFailureSummary(input: {
463
+ manifest: FusionArtifactManifest;
464
+ terminalError: FusionError;
465
+ progress: FusionRunProgress;
466
+ terminalState: Exclude<FusionTerminalState, 'completed'>;
467
+ createdAt: string;
468
+ }): FusionFailureSummaryV1 {
469
+ if (
470
+ (input.terminalState !== 'failed' && input.terminalState !== 'cancelled') ||
471
+ input.manifest.state !== input.terminalState
472
+ ) {
473
+ throw errorForArtifact('failure summary requires a matching failed/cancelled terminal manifest');
474
+ }
475
+ if (input.manifest.error !== input.terminalError.message) {
476
+ throw errorForArtifact('failure summary terminal error does not match the durable manifest');
477
+ }
478
+ if (canonicalJson(input.progress) !== canonicalJson(buildFusionRunProgress(input.manifest))) {
479
+ throw errorForArtifact('failure summary progress does not match the durable terminal manifest');
480
+ }
481
+ if (input.manifest.artifacts['failure-summary.json'] !== undefined) {
482
+ throw errorForArtifact('failure summary already exists in the terminal manifest');
483
+ }
484
+ const attempts = failureAttemptMetadata(input.manifest);
485
+ const evidence: FusionFailureEvidenceArtifact[] = Object.entries(input.manifest.artifacts)
486
+ .map(([name, ref]) => ({
487
+ name: assertFusionArtifactBasename(name),
488
+ classification: failureArtifactClassification(name, ref, input.manifest),
489
+ ref: { ...ref },
490
+ }))
491
+ .sort((left, right) => compareArtifactText(left.name, right.name));
492
+ return {
493
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
494
+ run_id: input.manifest.run_id,
495
+ workflow: input.manifest.workflow,
496
+ source: input.manifest.source,
497
+ terminal_state: input.terminalState,
498
+ created_at: input.createdAt,
499
+ answer: { present: false, reason: 'run_did_not_commit' },
500
+ failure: {
501
+ code: input.terminalError.code,
502
+ ...(input.terminalError.stage === undefined ? {} : { stage: input.terminalError.stage }),
503
+ ...(input.terminalError.slot === undefined ? {} : { slot: input.terminalError.slot }),
504
+ ...(input.terminalError.attempt === undefined
505
+ ? {}
506
+ : { attempt: input.terminalError.attempt }),
507
+ child_created: input.terminalError.childCreated,
508
+ message: terminalMessageMetadata(input.terminalError.message),
509
+ },
510
+ progress: input.progress,
511
+ usage_so_far: cloneFusionUsage(input.manifest.usage),
512
+ attempts: {
513
+ listed: attempts.filter((_attempt, index) => index < FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
514
+ omitted_count:
515
+ attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
516
+ },
517
+ evidence_artifacts: {
518
+ listed: evidence.filter((_artifact, index) => index < FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
519
+ omitted_count:
520
+ evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
521
+ },
522
+ remediation_ids: [
523
+ 'inspect_manifest_bound_evidence',
524
+ 'inspect_terminal_error',
525
+ 'split_or_reduce_work',
526
+ 'retry_same_route_after_operator_review',
527
+ ],
528
+ };
529
+ }
530
+
321
531
  export class FusionArtifactStore {
322
532
  private readonly runDirAbs: string;
323
533
  private readonly runDirDisplay: string;
@@ -534,6 +744,59 @@ export class FusionArtifactStore {
534
744
  });
535
745
  }
536
746
 
747
+ /**
748
+ * Writes the terminal evidence summary exactly once after writeError has made
749
+ * the manifest terminal. The summary deliberately contains refs only, never
750
+ * stage-output bodies.
751
+ */
752
+ async writeFailureSummary(summary: FusionFailureSummaryV1): Promise<FusionArtifactRef> {
753
+ if (
754
+ this.manifest.state !== 'failed' &&
755
+ this.manifest.state !== 'cancelled'
756
+ ) {
757
+ throw errorForArtifact('failure summary requires a failed/cancelled terminal manifest');
758
+ }
759
+ if (summary.terminal_state !== this.manifest.state) {
760
+ throw errorForArtifact('failure summary terminal state does not match the manifest');
761
+ }
762
+ if (
763
+ summary.run_id !== this.manifest.run_id ||
764
+ summary.workflow !== this.manifest.workflow ||
765
+ summary.source !== this.manifest.source
766
+ ) {
767
+ throw errorForArtifact('failure summary identity does not match the terminal manifest');
768
+ }
769
+ if (
770
+ summary.answer?.present !== false ||
771
+ summary.answer.reason !== 'run_did_not_commit'
772
+ ) {
773
+ throw errorForArtifact('failure summary must assert that no answer was committed');
774
+ }
775
+ if (this.manifest.error === undefined || this.manifest.artifacts['error.json'] === undefined) {
776
+ throw errorForArtifact('failure summary requires durable terminal error evidence');
777
+ }
778
+ if (
779
+ canonicalJson(summary.failure.message) !==
780
+ canonicalJson(terminalMessageMetadata(this.manifest.error))
781
+ ) {
782
+ throw errorForArtifact('failure summary terminal error metadata does not match the manifest');
783
+ }
784
+ if (canonicalJson(summary.progress) !== canonicalJson(buildFusionRunProgress(this.snapshot()))) {
785
+ throw errorForArtifact('failure summary progress does not match the terminal manifest');
786
+ }
787
+ if (canonicalJson(summary.usage_so_far) !== canonicalJson(this.manifest.usage)) {
788
+ throw errorForArtifact('failure summary usage does not match the terminal manifest');
789
+ }
790
+ if (this.manifest.artifacts['failure-summary.json'] !== undefined) {
791
+ throw errorForArtifact('failure summary is already bound in the manifest');
792
+ }
793
+ const bytes = Buffer.from(`${canonicalJson(summary)}\n`, 'utf8');
794
+ if (bytes.length > FUSION_FAILURE_SUMMARY_MAX_BYTES) {
795
+ throw errorForArtifact('failure summary exceeds its bounded diagnostics artifact limit');
796
+ }
797
+ return this.writeArtifact('failure-summary.json', bytes);
798
+ }
799
+
537
800
  async recordChildAttempt(input: RecordFusionChildAttemptInput): Promise<void> {
538
801
  const prefix = attemptPrefix(input.result.stage, input.result.slot, input.result.attempt);
539
802
  await this.writeArtifact(`${prefix}.system-prompt.txt`, input.systemPrompt);
@@ -664,6 +927,7 @@ export class FusionArtifactStore {
664
927
  });
665
928
  }
666
929
 
930
+ /** Writes a durable artifact then binds its exact bytes in the manifest. */
667
931
  private async writeArtifact(name: string, data: Buffer | string): Promise<FusionArtifactRef> {
668
932
  const absPath = this.artifactPath(name);
669
933
  const ref = await writePrivateFile(absPath, data);
@@ -674,9 +938,7 @@ export class FusionArtifactStore {
674
938
  }
675
939
 
676
940
  private artifactPath(name: string): string {
677
- if (name.length === 0 || name.includes('/') || name.includes('\\')) {
678
- throw errorForArtifact(`invalid fusion artifact name: ${name}`);
679
- }
941
+ assertFusionArtifactBasename(name);
680
942
  const absPath = join(this.runDirAbs, name);
681
943
  if (!pathInside(this.runDirAbs, absPath)) {
682
944
  throw errorForArtifact(`fusion artifact path escapes run directory: ${name}`);
@@ -5,7 +5,7 @@ import { getAgentDir } from '@earendil-works/pi-coding-agent';
5
5
  import type { Api, Model } from '@earendil-works/pi-ai';
6
6
  import { isJsonObject, parseJsonText, type JsonObject } from '../common.js';
7
7
  import { replaceFileDurable } from '../durable-fs.js';
8
- import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from './anthropic-attribution.js';
8
+ import { CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW } from '../anthropic-attribution.js';
9
9
  import {
10
10
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
11
11
  FusionError,