pi-background-tasks 1.0.7 → 2.1.1

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 (55) hide show
  1. package/README.md +8 -8
  2. package/TESTING.md +3 -3
  3. package/TEST_PLAN.md +6 -6
  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 +69 -50
  18. package/docs/operations/configuration.md +5 -3
  19. package/docs/read-before-edit.md +3 -0
  20. package/docs/reference/runtime-contracts.md +87 -82
  21. package/docs/reference/shortcuts-and-dock.md +2 -2
  22. package/docs/subsystems/background-task-runtime.md +7 -1
  23. package/docs/subsystems/docs-freshness-gate.md +5 -5
  24. package/docs/subsystems/fusion.md +22 -15
  25. package/docs/subsystems/host-ui-and-telemetry.md +1 -1
  26. package/docs/tools/bg_delegate.md +1 -1
  27. package/docs/tools/bg_kill.md +1 -1
  28. package/docs/tools/bg_logs.md +1 -1
  29. package/docs/tools/bg_result.md +14 -10
  30. package/docs/tools/bg_run.md +1 -1
  31. package/docs/tools/bg_run_pi_attested.md +1 -1
  32. package/docs/tools/bg_status.md +1 -1
  33. package/docs/tools/fusion_investigate.md +6 -4
  34. package/docs/tools/fusion_reason.md +5 -5
  35. package/docs/tools/fusion_research.md +6 -2
  36. package/docs/tools/fusion_validate.md +5 -3
  37. package/package.json +1 -1
  38. package/src/core/common.ts +50 -2
  39. package/src/core/fusion/anthropic-attribution.ts +1930 -0
  40. package/src/core/fusion/artifacts.ts +168 -21
  41. package/src/core/fusion/budget.ts +23 -23
  42. package/src/core/fusion/child-protocol.ts +115 -10
  43. package/src/core/fusion/claude-cache.ts +21 -0
  44. package/src/core/fusion/config.ts +10 -2
  45. package/src/core/fusion/orchestrator.ts +281 -77
  46. package/src/core/fusion/output-contract.ts +34 -0
  47. package/src/core/fusion/pi-child.ts +420 -12
  48. package/src/core/fusion/prompts.ts +11 -1
  49. package/src/core/fusion/result-package.ts +412 -0
  50. package/src/core/fusion/types.ts +67 -0
  51. package/src/core/registry.ts +187 -20
  52. package/src/delegate-extension.ts +130 -24
  53. package/src/extension.ts +17 -6
  54. package/src/fusion-child-extension.ts +117 -2
  55. package/src/fusion-extension.ts +308 -154
@@ -5,6 +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
9
  import {
9
10
  FUSION_MODEL_CONFIG_SCHEMA_VERSION,
10
11
  FusionError,
@@ -158,6 +159,13 @@ function requireContextWindow(model: Model<Api>, label: string): number {
158
159
  return Math.floor(value);
159
160
  }
160
161
 
162
+ function transportContextWindow(model: Model<Api>, label: string): number {
163
+ const advertised = requireContextWindow(model, label);
164
+ return model.provider === 'anthropic'
165
+ ? Math.min(advertised, CLAUDE_CODE_200K_SUBSCRIPTION_CONTEXT_WINDOW)
166
+ : advertised;
167
+ }
168
+
161
169
  function requireMaxOutputTokens(model: Model<Api>, label: string): number {
162
170
  const value = model.maxTokens;
163
171
  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
@@ -315,7 +323,7 @@ function resolveSelection(
315
323
  model: available.id,
316
324
  qualifiedId,
317
325
  thinkingLevel,
318
- contextWindow: requireContextWindow(available, slotLabel),
326
+ contextWindow: transportContextWindow(available, slotLabel),
319
327
  maxOutputTokens: requireMaxOutputTokens(available, slotLabel),
320
328
  };
321
329
  }
@@ -334,7 +342,7 @@ function resolveSelection(
334
342
  model: model.id,
335
343
  qualifiedId: selection,
336
344
  thinkingLevel,
337
- contextWindow: requireContextWindow(model, slotLabel),
345
+ contextWindow: transportContextWindow(model, slotLabel),
338
346
  maxOutputTokens: requireMaxOutputTokens(model, slotLabel),
339
347
  };
340
348
  }
@@ -1,7 +1,8 @@
1
1
  import { createHash, randomBytes as nodeRandomBytes } from 'node:crypto';
2
2
  import { canonicalJson } from '../attested-pi-run.js';
3
3
  import { parseJsonText } from '../common.js';
4
- import { FUSION_BUDGET_POLICY, FusionBudget, assertChildOutputWithinContract } from './budget.js';
4
+ import { FUSION_BUDGET_POLICY, FusionBudget } from './budget.js';
5
+ import { assertChildOutputWithinContract } from './output-contract.js';
5
6
  import {
6
7
  FusionArtifactStore,
7
8
  type CreateFusionArtifactStoreOptions,
@@ -39,7 +40,9 @@ import {
39
40
  FUSION_VALIDATE_CANDIDATE_SCHEMA_VERSION,
40
41
  FusionError,
41
42
  addFusionUsage,
43
+ cloneFusionUsage,
42
44
  createEmptyFusionUsage,
45
+ type FusionArtifactManifest,
43
46
  type FusionCalibrationViolation,
44
47
  type FusionCapability,
45
48
  type FusionCanonicalInputV3,
@@ -50,6 +53,7 @@ import {
50
53
  type FusionEvaluationV1,
51
54
  type FusionModelConfigV1,
52
55
  type FusionProgressEvent,
56
+ type FusionRunProgress,
53
57
  type FusionRunResult,
54
58
  type FusionSource,
55
59
  type FusionStage,
@@ -63,6 +67,12 @@ export type FusionChildRunner = (options: RunPiChildOptions) => Promise<FusionCh
63
67
  export type FusionProgressSink = (event: FusionProgressEvent) => void;
64
68
  export type FusionRandomBytes = (size: number) => Buffer;
65
69
 
70
+ export interface FusionRunReady {
71
+ runId: string;
72
+ artifactDir: string;
73
+ artifactDirAbs: string;
74
+ }
75
+
66
76
  type CandidateSlot = 1 | 2 | 3;
67
77
 
68
78
  export interface FusionWorkflowInput {
@@ -79,6 +89,12 @@ export interface FusionWorkflowInput {
79
89
  profile?: FusionWorkflowProfile | undefined;
80
90
  signal?: AbortSignal | undefined;
81
91
  onProgress?: FusionProgressSink | undefined;
92
+ /**
93
+ * Optional no-child-yet handoff. The orchestrator pauses here after durable
94
+ * preflight and budget admission, allowing a background registry receipt to
95
+ * become durable before candidate launch.
96
+ */
97
+ onReady?: ((ready: FusionRunReady) => Promise<void>) | undefined;
82
98
  }
83
99
 
84
100
  export interface FusionOrchestratorOptions {
@@ -120,7 +136,8 @@ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[])
120
136
 
121
137
  function isStrictCleanCanonicalInput(value: unknown): boolean {
122
138
  if (!isRecord(value)) return false;
123
- if (!hasOnlyKeys(value, ['schema_version', 'workflow', 'cwd', 'request', 'context'])) return false;
139
+ if (!hasOnlyKeys(value, ['schema_version', 'workflow', 'cwd', 'request', 'context']))
140
+ return false;
124
141
  const request = value['request'];
125
142
  if (!isRecord(request)) return false;
126
143
  if (!hasOnlyKeys(request, ['source', 'authority', 'text', 'sha256'])) return false;
@@ -131,7 +148,8 @@ function isStrictCleanCanonicalInput(value: unknown): boolean {
131
148
  const declaredSources = context['declared_sources'];
132
149
  if (!Array.isArray(declaredSources)) return false;
133
150
  for (const source of declaredSources) {
134
- if (!isRecord(source) || !hasOnlyKeys(source, ['url', 'canonical_url', 'purpose', 'sha256'])) return false;
151
+ if (!isRecord(source) || !hasOnlyKeys(source, ['url', 'canonical_url', 'purpose', 'sha256']))
152
+ return false;
135
153
  }
136
154
  return true;
137
155
  }
@@ -148,6 +166,7 @@ function asFusionError(error: unknown, artifactDir: string, messageOverride?: st
148
166
  if (error.slot !== undefined) details.slot = error.slot;
149
167
  if (error.attempt !== undefined) details.attempt = error.attempt;
150
168
  if (error.budget !== undefined) details.budget = error.budget;
169
+ if (error.runProgress !== undefined) details.runProgress = error.runProgress;
151
170
  return new FusionError(messageOverride ?? error.message, details);
152
171
  }
153
172
  return new FusionError(messageOverride ?? errorText(error), {
@@ -157,6 +176,103 @@ function asFusionError(error: unknown, artifactDir: string, messageOverride?: st
157
176
  });
158
177
  }
159
178
 
179
+ function fusionStageProgress(
180
+ manifest: FusionArtifactManifest,
181
+ stage: FusionStage,
182
+ ): FusionRunProgress['candidates'] {
183
+ const attempts = manifest.attempts.filter((attempt) => attempt.stage === stage);
184
+ const created = attempts.filter((attempt) => attempt.child_created).length;
185
+ const completed = attempts.filter(
186
+ (attempt) => attempt.child_created && attempt.status === 'completed',
187
+ ).length;
188
+ const failed = attempts.filter(
189
+ (attempt) => attempt.child_created && attempt.status === 'failed',
190
+ ).length;
191
+ const cancelled = attempts.filter(
192
+ (attempt) => attempt.child_created && attempt.status === 'cancelled',
193
+ ).length;
194
+ const completedByState =
195
+ stage === 'candidate'
196
+ ? completed >= 3
197
+ : stage === 'evaluation'
198
+ ? manifest.artifacts['evaluation.json'] !== undefined ||
199
+ manifest.state === 'evaluation_complete' ||
200
+ manifest.state === 'merging' ||
201
+ manifest.state === 'completed'
202
+ : manifest.artifacts['merged.md'] !== undefined || manifest.state === 'completed';
203
+ const progress: FusionRunProgress['candidates'] = {
204
+ status: completedByState ? 'completed' : created === 0 ? 'not_started' : 'incomplete',
205
+ attempts_recorded: attempts.length,
206
+ children_created: created,
207
+ children_completed: completed,
208
+ children_failed: failed,
209
+ children_cancelled: cancelled,
210
+ };
211
+ if (stage === 'candidate') {
212
+ const createdSlots = new Set(
213
+ attempts.flatMap((attempt) =>
214
+ attempt.child_created && attempt.slot !== undefined ? [attempt.slot] : [],
215
+ ),
216
+ );
217
+ progress.not_started_slots = 3 - createdSlots.size;
218
+ }
219
+ return progress;
220
+ }
221
+
222
+ export function buildFusionRunProgress(manifest: FusionArtifactManifest): FusionRunProgress {
223
+ return {
224
+ manifest_state: manifest.state,
225
+ candidates: fusionStageProgress(manifest, 'candidate'),
226
+ evaluation: fusionStageProgress(manifest, 'evaluation'),
227
+ merge: fusionStageProgress(manifest, 'merge'),
228
+ usage_so_far: cloneFusionUsage(manifest.usage),
229
+ };
230
+ }
231
+
232
+ function formatFusionRunStage(name: string, stage: FusionRunProgress['candidates']): string {
233
+ const notStarted =
234
+ stage.not_started_slots === undefined
235
+ ? ''
236
+ : `, ${String(stage.not_started_slots)} slot(s) not started`;
237
+ return `${name}=${stage.status} (${String(stage.children_created)} created, ${String(stage.children_completed)} completed, ${String(stage.children_failed)} failed, ${String(stage.children_cancelled)} cancelled${notStarted})`;
238
+ }
239
+
240
+ export function formatFusionRunProgress(progress: FusionRunProgress): string {
241
+ const usage = progress.usage_so_far;
242
+ const optionalUsage = [
243
+ usage.cacheWrite1h === undefined ? undefined : `cacheWrite1h=${String(usage.cacheWrite1h)}`,
244
+ usage.reasoning === undefined ? undefined : `reasoning=${String(usage.reasoning)}`,
245
+ ].filter((value): value is string => value !== undefined);
246
+ const optionalText = optionalUsage.length === 0 ? '' : `, ${optionalUsage.join(', ')}`;
247
+ return (
248
+ `Run progress from durable attempts: ${formatFusionRunStage('candidates', progress.candidates)}; ` +
249
+ `${formatFusionRunStage('evaluation', progress.evaluation)}; ` +
250
+ `${formatFusionRunStage('merge', progress.merge)}. ` +
251
+ `Usage so far: input=${String(usage.input)}, output=${String(usage.output)}, cacheRead=${String(usage.cacheRead)}, cacheWrite=${String(usage.cacheWrite)}${optionalText}, totalTokens=${String(usage.totalTokens)}, ` +
252
+ `cost.input=${String(usage.cost.input)}, cost.output=${String(usage.cost.output)}, cost.cacheRead=${String(usage.cost.cacheRead)}, cost.cacheWrite=${String(usage.cost.cacheWrite)}, cost.total=${String(usage.cost.total)}.`
253
+ );
254
+ }
255
+
256
+ function withRunProgress(
257
+ error: unknown,
258
+ artifactDir: string,
259
+ progress: FusionRunProgress,
260
+ ): FusionError {
261
+ const base = asFusionError(error, artifactDir);
262
+ const details: FusionErrorDetails = {
263
+ code: base.code,
264
+ artifactDir,
265
+ transient: base.transient,
266
+ childCreated: base.childCreated,
267
+ runProgress: progress,
268
+ };
269
+ if (base.stage !== undefined) details.stage = base.stage;
270
+ if (base.slot !== undefined) details.slot = base.slot;
271
+ if (base.attempt !== undefined) details.attempt = base.attempt;
272
+ if (base.budget !== undefined) details.budget = base.budget;
273
+ return new FusionError(`${base.message}\n${formatFusionRunProgress(progress)}`, details);
274
+ }
275
+
160
276
  function withTerminalArtifactFailure(
161
277
  error: unknown,
162
278
  artifactDir: string,
@@ -187,7 +303,9 @@ function recordFailureInput(
187
303
  error: error.message,
188
304
  status: error.code === 'child_cancelled' ? 'cancelled' : 'failed',
189
305
  responseKind,
306
+ childCreated: error.childCreated,
190
307
  usage: error.usage,
308
+ ...(error.outputRecovery === undefined ? {} : { outputRecovery: error.outputRecovery }),
191
309
  };
192
310
  if (slot !== undefined) base.slot = slot;
193
311
  if (error.provider !== undefined) base.provider = error.provider;
@@ -207,6 +325,7 @@ function recordFailureInput(
207
325
  status:
208
326
  error instanceof FusionError && error.code === 'child_cancelled' ? 'cancelled' : 'failed',
209
327
  responseKind,
328
+ childCreated: error instanceof FusionError ? error.childCreated : false,
210
329
  };
211
330
  if (slot !== undefined) base.slot = slot;
212
331
  return base;
@@ -231,6 +350,7 @@ function childOptions(
231
350
  slot?: CandidateSlot,
232
351
  toolCallLogPath?: string,
233
352
  sourcePolicy?: { path: string; sha256: string },
353
+ candidateOutputRecoveryPath?: string,
234
354
  ): RunPiChildOptions {
235
355
  const out: RunPiChildOptions = {
236
356
  stage,
@@ -245,6 +365,8 @@ function childOptions(
245
365
  if (slot !== undefined) out.slot = slot;
246
366
  if (toolCallLogPath !== undefined) out.toolCallLogPath = toolCallLogPath;
247
367
  if (sourcePolicy !== undefined) out.sourcePolicy = sourcePolicy;
368
+ if (candidateOutputRecoveryPath !== undefined)
369
+ out.candidateOutputRecoveryPath = candidateOutputRecoveryPath;
248
370
  return out;
249
371
  }
250
372
 
@@ -276,7 +398,10 @@ function parseEvaluationAttempt(
276
398
  };
277
399
  }
278
400
  if (expectedValidationFindings !== undefined) {
279
- const accountingErrors = validateEvaluationAccountsForSourceFindings(result.value, expectedValidationFindings);
401
+ const accountingErrors = validateEvaluationAccountsForSourceFindings(
402
+ result.value,
403
+ expectedValidationFindings,
404
+ );
280
405
  if (accountingErrors.length > 0) return { evaluation: undefined, errors: accountingErrors };
281
406
  }
282
407
  return { evaluation: result.value, errors: [] };
@@ -376,7 +501,11 @@ function anonymousCandidates(
376
501
  }
377
502
 
378
503
  interface ValidationSourceData {
379
- candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate];
504
+ candidates: readonly [
505
+ AnonymousFusionCandidate,
506
+ AnonymousFusionCandidate,
507
+ AnonymousFusionCandidate,
508
+ ];
380
509
  findings: readonly FusionValidationFindingRecord[];
381
510
  verified: readonly string[];
382
511
  limitations: readonly string[];
@@ -398,7 +527,11 @@ function boundedContractError(error: unknown): string {
398
527
  * an explicit limitation; two or more still fail the workflow loudly.
399
528
  */
400
529
  async function prepareValidationSourceData(
401
- candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate],
530
+ candidates: readonly [
531
+ AnonymousFusionCandidate,
532
+ AnonymousFusionCandidate,
533
+ AnonymousFusionCandidate,
534
+ ],
402
535
  anonymousMap: Record<FusionCandidateId, CandidateSlot>,
403
536
  store: FusionArtifactStore,
404
537
  ): Promise<ValidationSourceData> {
@@ -415,7 +548,10 @@ async function prepareValidationSourceData(
415
548
 
416
549
  for (const candidate of prepared) {
417
550
  try {
418
- const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
551
+ const report = parseFusionValidationCandidateReport(
552
+ candidate.response,
553
+ candidate.candidate_id,
554
+ );
419
555
  findings.push(...report.findings);
420
556
  verified.push(...report.verified);
421
557
  limitations.push(...report.limitations);
@@ -435,7 +571,8 @@ async function prepareValidationSourceData(
435
571
  normalization: recovered.normalization,
436
572
  original_sha256: sha256Text(candidate.response),
437
573
  forwarded_sha256: sha256Text(recovered.response),
438
- warning: 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
574
+ warning:
575
+ 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
439
576
  },
440
577
  });
441
578
  candidate.response = recovered.response;
@@ -506,14 +643,18 @@ function validateEvaluationAccountsForSourceFindings(
506
643
  }
507
644
  const expected = sourceFindings.map((finding) => canonicalJson(finding)).sort();
508
645
  const actual = accounting.findings.map((finding) => canonicalJson(finding)).sort();
509
- if (expected.length !== actual.length || expected.some((value, index) => value !== actual[index])) {
510
- errors.push('validation evaluator validation_accounting.findings must exactly equal host-assigned source findings');
646
+ if (
647
+ expected.length !== actual.length ||
648
+ expected.some((value, index) => value !== actual[index])
649
+ ) {
650
+ errors.push(
651
+ 'validation evaluator validation_accounting.findings must exactly equal host-assigned source findings',
652
+ );
511
653
  }
512
654
  errors.push(...validateFusionFindingAccounting(accounting));
513
655
  return errors;
514
656
  }
515
657
 
516
-
517
658
  function resolveRunProfile(input: FusionWorkflowInput): FusionWorkflowProfile {
518
659
  if (input.profile !== undefined) return fusionWorkflowProfile(input.profile.id);
519
660
  const workflow = input.canonicalInput.workflow;
@@ -564,11 +705,17 @@ export class FusionOrchestrator {
564
705
  { code: 'orchestration_failed', childCreated: false },
565
706
  );
566
707
  }
567
- if (profile.contextKind === 'clean_task' && !isStrictCleanCanonicalInput(input.canonicalInput)) {
568
- throw new FusionError('clean-task fusion input must not carry parent context fields and must match the strict clean canonical shape', {
569
- code: 'orchestration_failed',
570
- childCreated: false,
571
- });
708
+ if (
709
+ profile.contextKind === 'clean_task' &&
710
+ !isStrictCleanCanonicalInput(input.canonicalInput)
711
+ ) {
712
+ throw new FusionError(
713
+ 'clean-task fusion input must not carry parent context fields and must match the strict clean canonical shape',
714
+ {
715
+ code: 'orchestration_failed',
716
+ childCreated: false,
717
+ },
718
+ );
572
719
  }
573
720
  const candidateCapability = assertWorkflowCapability(profile, input.candidateCapability);
574
721
  const storeOptions: CreateFusionArtifactStoreOptions = {
@@ -589,16 +736,22 @@ export class FusionOrchestrator {
589
736
  try {
590
737
  serializedParsed = parseJsonText(input.canonicalInputSerialized);
591
738
  } catch (error) {
592
- throw new FusionError(`fusion canonical input artifact is not valid JSON: ${errorText(error)}`, {
593
- code: 'orchestration_failed',
594
- childCreated: false,
595
- });
739
+ throw new FusionError(
740
+ `fusion canonical input artifact is not valid JSON: ${errorText(error)}`,
741
+ {
742
+ code: 'orchestration_failed',
743
+ childCreated: false,
744
+ },
745
+ );
596
746
  }
597
747
  if (canonicalJson(serializedParsed) !== canonicalJson(input.canonicalInput)) {
598
- throw new FusionError('fusion canonical input serialized bytes do not match canonical input object', {
599
- code: 'orchestration_failed',
600
- childCreated: false,
601
- });
748
+ throw new FusionError(
749
+ 'fusion canonical input serialized bytes do not match canonical input object',
750
+ {
751
+ code: 'orchestration_failed',
752
+ childCreated: false,
753
+ },
754
+ );
602
755
  }
603
756
  const store = await this.createArtifactStore(storeOptions);
604
757
  input.onProgress?.({ type: 'state', state: 'initializing' });
@@ -608,10 +761,13 @@ export class FusionOrchestrator {
608
761
  await store.writeCanonicalInput(input.canonicalInputSerialized);
609
762
  if (inputContextKind === 'session_projection') {
610
763
  if (input.contextLedger === undefined) {
611
- throw new FusionError('session-projection fusion input requires an omission ledger artifact', {
612
- code: 'orchestration_failed',
613
- childCreated: false,
614
- });
764
+ throw new FusionError(
765
+ 'session-projection fusion input requires an omission ledger artifact',
766
+ {
767
+ code: 'orchestration_failed',
768
+ childCreated: false,
769
+ },
770
+ );
615
771
  }
616
772
  await store.writeContextLedger(input.contextLedger);
617
773
  } else if (input.contextLedger !== undefined) {
@@ -649,6 +805,17 @@ export class FusionOrchestrator {
649
805
  error: 'fusion budget utilization warning',
650
806
  });
651
807
  }
808
+ await input.onReady?.({
809
+ runId: store.runId,
810
+ artifactDir: store.artifactDir,
811
+ artifactDirAbs: store.artifactDirAbs,
812
+ });
813
+ if (input.signal?.aborted === true) {
814
+ throw new FusionError('fusion run cancelled before launch', {
815
+ code: 'child_cancelled',
816
+ childCreated: false,
817
+ });
818
+ }
652
819
  await store.transition('candidates_running');
653
820
  input.onProgress?.({ type: 'state', state: 'candidates_running' });
654
821
  const candidateResults = await this.runCandidates(
@@ -667,9 +834,10 @@ export class FusionOrchestrator {
667
834
  // Persist the blind mapping before workflow-specific contract parsing
668
835
  // so a failed validation remains attributable to its durable slot artifact.
669
836
  await store.setAnonymousMap(shuffled.map);
670
- const validationData = profile.id === 'validate'
671
- ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
672
- : undefined;
837
+ const validationData =
838
+ profile.id === 'validate'
839
+ ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
840
+ : undefined;
673
841
  const evaluationCandidates = validationData?.candidates ?? shuffled.candidates;
674
842
  const blindInput = buildBlindEvaluationInput(
675
843
  input.canonicalInput,
@@ -715,7 +883,12 @@ export class FusionOrchestrator {
715
883
  'md',
716
884
  );
717
885
  addFusionUsage(usage, merged.usage);
718
- await store.recordChildAttempt({ result: merged, systemPrompt: profile.mergerSystemPrompt, prompt: mergePrompt, responseKind: 'md' });
886
+ await store.recordChildAttempt({
887
+ result: merged,
888
+ systemPrompt: profile.mergerSystemPrompt,
889
+ prompt: mergePrompt,
890
+ responseKind: 'md',
891
+ });
719
892
  await this.recordCalibrationObservation(
720
893
  input,
721
894
  store,
@@ -731,52 +904,67 @@ export class FusionOrchestrator {
731
904
  if (profile.id === 'validate') {
732
905
  const accounting = evaluation.validation_accounting;
733
906
  if (accounting === undefined) {
734
- throw new FusionError('fusion_validate evaluation completed without validation accounting', {
735
- code: 'evaluation_invalid',
736
- stage: 'merge',
737
- });
907
+ throw new FusionError(
908
+ 'fusion_validate evaluation completed without validation accounting',
909
+ {
910
+ code: 'evaluation_invalid',
911
+ stage: 'merge',
912
+ },
913
+ );
738
914
  }
739
915
  finalMergedText = renderValidatedFusionValidationReport(accounting, validationData);
740
916
  }
741
- if (finalMergedText !== merged.text) assertChildOutputWithinContract('merge', finalMergedText);
742
- await store.writeMerged(finalMergedText);
917
+ if (finalMergedText !== merged.text)
918
+ assertChildOutputWithinContract('merge', finalMergedText);
919
+ const mergedRef = await store.writeMerged(finalMergedText);
743
920
  await store.setUsage(usage);
744
- await store.transition('completed');
745
- input.onProgress?.({ type: 'completed', runId: store.runId, artifactDir: store.artifactDir });
746
- return {
747
- mergedText: finalMergedText,
748
- details: {
749
- schema_version: FUSION_RESULT_SCHEMA_VERSION,
750
- run_id: store.runId,
751
- workflow: profile.id,
752
- source: input.source,
753
- status: 'completed',
754
- artifact_dir: store.artifactDir,
755
- context: { kind: inputContextKind, policy_id: input.canonicalInput.context?.policy_id ?? 'fusion-session-projection-v1' },
756
- tool_policy: { candidate_tools: profile.candidateTools, evaluation_tools: [], merge_tools: [] },
757
- models: store.snapshot().models,
758
- evaluator_attempts: store
759
- .snapshot()
760
- .attempts.filter((attempt) => attempt.stage === 'evaluation').length,
761
- usage,
762
- budget: {
763
- policy_id: FUSION_BUDGET_POLICY.id,
764
- calibration_version: budgetPlan.policy.calibration_version,
765
- route_table: budget.routes,
766
- rate_sources: budget.resultRateSources,
767
- unknown_provider_warnings: budget.unknownProviderWarnings,
768
- calibration_warnings: calibrationWarnings,
769
- },
921
+ const details: FusionRunResult['details'] = {
922
+ schema_version: FUSION_RESULT_SCHEMA_VERSION,
923
+ run_id: store.runId,
924
+ workflow: profile.id,
925
+ source: input.source,
926
+ status: 'completed',
927
+ artifact_dir: store.artifactDir,
928
+ context: {
929
+ kind: inputContextKind,
930
+ policy_id: input.canonicalInput.context?.policy_id ?? 'fusion-session-projection-v1',
931
+ },
932
+ tool_policy: {
933
+ candidate_tools: profile.candidateTools,
934
+ evaluation_tools: [],
935
+ merge_tools: [],
936
+ },
937
+ models: store.snapshot().models,
938
+ evaluator_attempts: store
939
+ .snapshot()
940
+ .attempts.filter((attempt) => attempt.stage === 'evaluation').length,
941
+ usage,
942
+ budget: {
943
+ policy_id: FUSION_BUDGET_POLICY.id,
944
+ calibration_version: budgetPlan.policy.calibration_version,
945
+ route_table: budget.routes,
946
+ rate_sources: budget.resultRateSources,
947
+ unknown_provider_warnings: budget.unknownProviderWarnings,
948
+ calibration_warnings: calibrationWarnings,
770
949
  },
771
950
  };
951
+ await store.writeCommittedResult(mergedRef, details);
952
+ await store.transition('completed');
953
+ input.onProgress?.({ type: 'completed', runId: store.runId, artifactDir: store.artifactDir });
954
+ return { mergedText: finalMergedText, details };
772
955
  } catch (error) {
773
956
  const cancelled =
774
957
  input.signal?.aborted === true ||
775
958
  (error instanceof FusionError && error.code === 'child_cancelled');
776
- const message = errorText(error);
959
+ let terminalError: FusionError;
777
960
  try {
778
961
  await store.setUsage(usage);
779
- await store.writeError(cancelled ? 'cancelled' : 'failed', message);
962
+ terminalError = withRunProgress(
963
+ error,
964
+ store.artifactDir,
965
+ buildFusionRunProgress(store.snapshot()),
966
+ );
967
+ await store.writeError(cancelled ? 'cancelled' : 'failed', terminalError.message);
780
968
  } catch (artifactError) {
781
969
  throw withTerminalArtifactFailure(error, store.artifactDir, artifactError);
782
970
  }
@@ -785,17 +973,17 @@ export class FusionOrchestrator {
785
973
  type: 'cancelled',
786
974
  runId: store.runId,
787
975
  artifactDir: store.artifactDir,
788
- reason: message,
976
+ reason: terminalError.message,
789
977
  });
790
978
  } else {
791
979
  input.onProgress?.({
792
980
  type: 'failed',
793
981
  runId: store.runId,
794
982
  artifactDir: store.artifactDir,
795
- error: message,
983
+ error: terminalError.message,
796
984
  });
797
985
  }
798
- throw asFusionError(error, store.artifactDir);
986
+ throw terminalError;
799
987
  }
800
988
  }
801
989
 
@@ -842,7 +1030,12 @@ export class FusionOrchestrator {
842
1030
  slot,
843
1031
  profile.id === 'validate' ? 'txt' : 'md',
844
1032
  ).then(async (result) => {
845
- await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: profile.id === 'validate' ? 'txt' : 'md' });
1033
+ await store.recordChildAttempt({
1034
+ result,
1035
+ systemPrompt,
1036
+ prompt,
1037
+ responseKind: profile.id === 'validate' ? 'txt' : 'md',
1038
+ });
846
1039
  await this.recordCalibrationObservation(
847
1040
  input,
848
1041
  store,
@@ -854,12 +1047,12 @@ export class FusionOrchestrator {
854
1047
  result,
855
1048
  slot,
856
1049
  );
857
- // The response is durable before the contract check, so an oversized
858
- // answer is preserved as evidence rather than lost.
859
- assertChildOutputWithinContract('candidate', result.text);
860
- completed += 1;
1050
+ // The response and its consumed usage are durable before the contract
1051
+ // check, so an oversized answer is preserved and accounted rather than lost.
861
1052
  addFusionUsage(usage, result.usage);
862
1053
  await store.setUsage(usage);
1054
+ assertChildOutputWithinContract('candidate', result.text);
1055
+ completed += 1;
863
1056
  input.onProgress?.({ type: 'candidate_completed', slot, completed, total: 3 });
864
1057
  return { slot, result };
865
1058
  });
@@ -1054,8 +1247,10 @@ export class FusionOrchestrator {
1054
1247
  ? store.childToolCallLogPath(stage, slot, logicalAttempt)
1055
1248
  : undefined;
1056
1249
  const sourcePolicy =
1057
- capability === 'research'
1058
- ? store.sourcePolicyLaunchReference()
1250
+ capability === 'research' ? store.sourcePolicyLaunchReference() : undefined;
1251
+ const candidateOutputRecoveryPath =
1252
+ stage === 'candidate' && slot !== undefined
1253
+ ? store.childOutputRecoveryPath(slot, logicalAttempt, responseKind)
1059
1254
  : undefined;
1060
1255
  try {
1061
1256
  return await this.childRunner(
@@ -1071,13 +1266,22 @@ export class FusionOrchestrator {
1071
1266
  slot,
1072
1267
  toolCallLogPath,
1073
1268
  sourcePolicy,
1269
+ candidateOutputRecoveryPath,
1074
1270
  ),
1075
1271
  );
1076
1272
  } catch (error) {
1077
1273
  if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;
1078
1274
  addFailedChildUsage(usage, error);
1079
1275
  await store.recordFailedAttempt(
1080
- recordFailureInput(error, stage, slot, logicalAttempt, systemPrompt, userPrompt, responseKind),
1276
+ recordFailureInput(
1277
+ error,
1278
+ stage,
1279
+ slot,
1280
+ logicalAttempt,
1281
+ systemPrompt,
1282
+ userPrompt,
1283
+ responseKind,
1284
+ ),
1081
1285
  );
1082
1286
  await store.setUsage(usage);
1083
1287
  throw error;
@@ -0,0 +1,34 @@
1
+ import { FusionError, type FusionStage } from './types.js';
2
+
3
+ /** Hard output contracts are measured over the JSON rendering embedded downstream. */
4
+ export const FUSION_CANDIDATE_MAX_OUTPUT_BYTES = 48 * 1024;
5
+ export const FUSION_EVALUATION_MAX_OUTPUT_BYTES = 64 * 1024;
6
+ export const FUSION_MERGE_MAX_OUTPUT_BYTES = 64 * 1024;
7
+ export const FUSION_DIAGNOSTICS_MAX_BYTES = 8 * 1024;
8
+
9
+ const FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY =
10
+ FUSION_CANDIDATE_MAX_OUTPUT_BYTES.toLocaleString('en-US');
11
+
12
+ export const FUSION_CANDIDATE_OUTPUT_CONTRACT_INSTRUCTION = `Your complete response must be at most ${FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY} JSON-rendered UTF-8 bytes. If the requested scope cannot fit, prioritize the most important findings and explicitly state limitations.`;
13
+
14
+ export const FUSION_CANDIDATE_OUTPUT_COMPRESSION_PROMPT = `Compress and restructure only your immediately previous answer so the complete replacement is at most ${FUSION_CANDIDATE_MAX_OUTPUT_BYTES_DISPLAY} JSON-rendered UTF-8 bytes. Do not investigate again, do not use tools, and do not add new evidence. Preserve the most important findings and evidence already present, state material limitations, obey the original output format, and output only the replacement answer.`;
15
+
16
+ export function fusionJsonRenderedTextBytes(text: string): number {
17
+ return Buffer.byteLength(JSON.stringify(text), 'utf8');
18
+ }
19
+
20
+ export function fusionOutputContractBytes(stage: FusionStage): number {
21
+ if (stage === 'candidate') return FUSION_CANDIDATE_MAX_OUTPUT_BYTES;
22
+ if (stage === 'evaluation') return FUSION_EVALUATION_MAX_OUTPUT_BYTES;
23
+ return FUSION_MERGE_MAX_OUTPUT_BYTES;
24
+ }
25
+
26
+ export function assertChildOutputWithinContract(stage: FusionStage, text: string): void {
27
+ const bytes = fusionJsonRenderedTextBytes(text);
28
+ const allowed = fusionOutputContractBytes(stage);
29
+ if (bytes <= allowed) return;
30
+ throw new FusionError(
31
+ `fusion ${stage} response is ${String(bytes)} JSON-rendered bytes, exceeding the ${String(allowed)}-byte output contract for that stage; the response is preserved in the run artifacts and is not forwarded or truncated`,
32
+ { code: 'child_output_cap', stage, childCreated: true },
33
+ );
34
+ }