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
@@ -63,6 +63,12 @@ export type FusionChildRunner = (options: RunPiChildOptions) => Promise<FusionCh
63
63
  export type FusionProgressSink = (event: FusionProgressEvent) => void;
64
64
  export type FusionRandomBytes = (size: number) => Buffer;
65
65
 
66
+ export interface FusionRunReady {
67
+ runId: string;
68
+ artifactDir: string;
69
+ artifactDirAbs: string;
70
+ }
71
+
66
72
  type CandidateSlot = 1 | 2 | 3;
67
73
 
68
74
  export interface FusionWorkflowInput {
@@ -79,6 +85,12 @@ export interface FusionWorkflowInput {
79
85
  profile?: FusionWorkflowProfile | undefined;
80
86
  signal?: AbortSignal | undefined;
81
87
  onProgress?: FusionProgressSink | undefined;
88
+ /**
89
+ * Optional no-child-yet handoff. The orchestrator pauses here after durable
90
+ * preflight and budget admission, allowing a background registry receipt to
91
+ * become durable before candidate launch.
92
+ */
93
+ onReady?: ((ready: FusionRunReady) => Promise<void>) | undefined;
82
94
  }
83
95
 
84
96
  export interface FusionOrchestratorOptions {
@@ -120,7 +132,8 @@ function hasOnlyKeys(value: Record<string, unknown>, allowed: readonly string[])
120
132
 
121
133
  function isStrictCleanCanonicalInput(value: unknown): boolean {
122
134
  if (!isRecord(value)) return false;
123
- if (!hasOnlyKeys(value, ['schema_version', 'workflow', 'cwd', 'request', 'context'])) return false;
135
+ if (!hasOnlyKeys(value, ['schema_version', 'workflow', 'cwd', 'request', 'context']))
136
+ return false;
124
137
  const request = value['request'];
125
138
  if (!isRecord(request)) return false;
126
139
  if (!hasOnlyKeys(request, ['source', 'authority', 'text', 'sha256'])) return false;
@@ -131,7 +144,8 @@ function isStrictCleanCanonicalInput(value: unknown): boolean {
131
144
  const declaredSources = context['declared_sources'];
132
145
  if (!Array.isArray(declaredSources)) return false;
133
146
  for (const source of declaredSources) {
134
- if (!isRecord(source) || !hasOnlyKeys(source, ['url', 'canonical_url', 'purpose', 'sha256'])) return false;
147
+ if (!isRecord(source) || !hasOnlyKeys(source, ['url', 'canonical_url', 'purpose', 'sha256']))
148
+ return false;
135
149
  }
136
150
  return true;
137
151
  }
@@ -276,7 +290,10 @@ function parseEvaluationAttempt(
276
290
  };
277
291
  }
278
292
  if (expectedValidationFindings !== undefined) {
279
- const accountingErrors = validateEvaluationAccountsForSourceFindings(result.value, expectedValidationFindings);
293
+ const accountingErrors = validateEvaluationAccountsForSourceFindings(
294
+ result.value,
295
+ expectedValidationFindings,
296
+ );
280
297
  if (accountingErrors.length > 0) return { evaluation: undefined, errors: accountingErrors };
281
298
  }
282
299
  return { evaluation: result.value, errors: [] };
@@ -376,7 +393,11 @@ function anonymousCandidates(
376
393
  }
377
394
 
378
395
  interface ValidationSourceData {
379
- candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate];
396
+ candidates: readonly [
397
+ AnonymousFusionCandidate,
398
+ AnonymousFusionCandidate,
399
+ AnonymousFusionCandidate,
400
+ ];
380
401
  findings: readonly FusionValidationFindingRecord[];
381
402
  verified: readonly string[];
382
403
  limitations: readonly string[];
@@ -398,7 +419,11 @@ function boundedContractError(error: unknown): string {
398
419
  * an explicit limitation; two or more still fail the workflow loudly.
399
420
  */
400
421
  async function prepareValidationSourceData(
401
- candidates: readonly [AnonymousFusionCandidate, AnonymousFusionCandidate, AnonymousFusionCandidate],
422
+ candidates: readonly [
423
+ AnonymousFusionCandidate,
424
+ AnonymousFusionCandidate,
425
+ AnonymousFusionCandidate,
426
+ ],
402
427
  anonymousMap: Record<FusionCandidateId, CandidateSlot>,
403
428
  store: FusionArtifactStore,
404
429
  ): Promise<ValidationSourceData> {
@@ -415,7 +440,10 @@ async function prepareValidationSourceData(
415
440
 
416
441
  for (const candidate of prepared) {
417
442
  try {
418
- const report = parseFusionValidationCandidateReport(candidate.response, candidate.candidate_id);
443
+ const report = parseFusionValidationCandidateReport(
444
+ candidate.response,
445
+ candidate.candidate_id,
446
+ );
419
447
  findings.push(...report.findings);
420
448
  verified.push(...report.verified);
421
449
  limitations.push(...report.limitations);
@@ -435,7 +463,8 @@ async function prepareValidationSourceData(
435
463
  normalization: recovered.normalization,
436
464
  original_sha256: sha256Text(candidate.response),
437
465
  forwarded_sha256: sha256Text(recovered.response),
438
- warning: 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
466
+ warning:
467
+ 'Candidate output violated the bare-JSON contract; a single complete JSON fence was removed and recorded.',
439
468
  },
440
469
  });
441
470
  candidate.response = recovered.response;
@@ -506,14 +535,18 @@ function validateEvaluationAccountsForSourceFindings(
506
535
  }
507
536
  const expected = sourceFindings.map((finding) => canonicalJson(finding)).sort();
508
537
  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');
538
+ if (
539
+ expected.length !== actual.length ||
540
+ expected.some((value, index) => value !== actual[index])
541
+ ) {
542
+ errors.push(
543
+ 'validation evaluator validation_accounting.findings must exactly equal host-assigned source findings',
544
+ );
511
545
  }
512
546
  errors.push(...validateFusionFindingAccounting(accounting));
513
547
  return errors;
514
548
  }
515
549
 
516
-
517
550
  function resolveRunProfile(input: FusionWorkflowInput): FusionWorkflowProfile {
518
551
  if (input.profile !== undefined) return fusionWorkflowProfile(input.profile.id);
519
552
  const workflow = input.canonicalInput.workflow;
@@ -564,11 +597,17 @@ export class FusionOrchestrator {
564
597
  { code: 'orchestration_failed', childCreated: false },
565
598
  );
566
599
  }
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
- });
600
+ if (
601
+ profile.contextKind === 'clean_task' &&
602
+ !isStrictCleanCanonicalInput(input.canonicalInput)
603
+ ) {
604
+ throw new FusionError(
605
+ 'clean-task fusion input must not carry parent context fields and must match the strict clean canonical shape',
606
+ {
607
+ code: 'orchestration_failed',
608
+ childCreated: false,
609
+ },
610
+ );
572
611
  }
573
612
  const candidateCapability = assertWorkflowCapability(profile, input.candidateCapability);
574
613
  const storeOptions: CreateFusionArtifactStoreOptions = {
@@ -589,16 +628,22 @@ export class FusionOrchestrator {
589
628
  try {
590
629
  serializedParsed = parseJsonText(input.canonicalInputSerialized);
591
630
  } catch (error) {
592
- throw new FusionError(`fusion canonical input artifact is not valid JSON: ${errorText(error)}`, {
593
- code: 'orchestration_failed',
594
- childCreated: false,
595
- });
631
+ throw new FusionError(
632
+ `fusion canonical input artifact is not valid JSON: ${errorText(error)}`,
633
+ {
634
+ code: 'orchestration_failed',
635
+ childCreated: false,
636
+ },
637
+ );
596
638
  }
597
639
  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
- });
640
+ throw new FusionError(
641
+ 'fusion canonical input serialized bytes do not match canonical input object',
642
+ {
643
+ code: 'orchestration_failed',
644
+ childCreated: false,
645
+ },
646
+ );
602
647
  }
603
648
  const store = await this.createArtifactStore(storeOptions);
604
649
  input.onProgress?.({ type: 'state', state: 'initializing' });
@@ -608,10 +653,13 @@ export class FusionOrchestrator {
608
653
  await store.writeCanonicalInput(input.canonicalInputSerialized);
609
654
  if (inputContextKind === 'session_projection') {
610
655
  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
- });
656
+ throw new FusionError(
657
+ 'session-projection fusion input requires an omission ledger artifact',
658
+ {
659
+ code: 'orchestration_failed',
660
+ childCreated: false,
661
+ },
662
+ );
615
663
  }
616
664
  await store.writeContextLedger(input.contextLedger);
617
665
  } else if (input.contextLedger !== undefined) {
@@ -649,6 +697,17 @@ export class FusionOrchestrator {
649
697
  error: 'fusion budget utilization warning',
650
698
  });
651
699
  }
700
+ await input.onReady?.({
701
+ runId: store.runId,
702
+ artifactDir: store.artifactDir,
703
+ artifactDirAbs: store.artifactDirAbs,
704
+ });
705
+ if (input.signal?.aborted === true) {
706
+ throw new FusionError('fusion run cancelled before launch', {
707
+ code: 'child_cancelled',
708
+ childCreated: false,
709
+ });
710
+ }
652
711
  await store.transition('candidates_running');
653
712
  input.onProgress?.({ type: 'state', state: 'candidates_running' });
654
713
  const candidateResults = await this.runCandidates(
@@ -667,9 +726,10 @@ export class FusionOrchestrator {
667
726
  // Persist the blind mapping before workflow-specific contract parsing
668
727
  // so a failed validation remains attributable to its durable slot artifact.
669
728
  await store.setAnonymousMap(shuffled.map);
670
- const validationData = profile.id === 'validate'
671
- ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
672
- : undefined;
729
+ const validationData =
730
+ profile.id === 'validate'
731
+ ? await prepareValidationSourceData(shuffled.candidates, shuffled.map, store)
732
+ : undefined;
673
733
  const evaluationCandidates = validationData?.candidates ?? shuffled.candidates;
674
734
  const blindInput = buildBlindEvaluationInput(
675
735
  input.canonicalInput,
@@ -715,7 +775,12 @@ export class FusionOrchestrator {
715
775
  'md',
716
776
  );
717
777
  addFusionUsage(usage, merged.usage);
718
- await store.recordChildAttempt({ result: merged, systemPrompt: profile.mergerSystemPrompt, prompt: mergePrompt, responseKind: 'md' });
778
+ await store.recordChildAttempt({
779
+ result: merged,
780
+ systemPrompt: profile.mergerSystemPrompt,
781
+ prompt: mergePrompt,
782
+ responseKind: 'md',
783
+ });
719
784
  await this.recordCalibrationObservation(
720
785
  input,
721
786
  store,
@@ -731,44 +796,54 @@ export class FusionOrchestrator {
731
796
  if (profile.id === 'validate') {
732
797
  const accounting = evaluation.validation_accounting;
733
798
  if (accounting === undefined) {
734
- throw new FusionError('fusion_validate evaluation completed without validation accounting', {
735
- code: 'evaluation_invalid',
736
- stage: 'merge',
737
- });
799
+ throw new FusionError(
800
+ 'fusion_validate evaluation completed without validation accounting',
801
+ {
802
+ code: 'evaluation_invalid',
803
+ stage: 'merge',
804
+ },
805
+ );
738
806
  }
739
807
  finalMergedText = renderValidatedFusionValidationReport(accounting, validationData);
740
808
  }
741
- if (finalMergedText !== merged.text) assertChildOutputWithinContract('merge', finalMergedText);
742
- await store.writeMerged(finalMergedText);
809
+ if (finalMergedText !== merged.text)
810
+ assertChildOutputWithinContract('merge', finalMergedText);
811
+ const mergedRef = await store.writeMerged(finalMergedText);
743
812
  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
- },
813
+ const details: FusionRunResult['details'] = {
814
+ schema_version: FUSION_RESULT_SCHEMA_VERSION,
815
+ run_id: store.runId,
816
+ workflow: profile.id,
817
+ source: input.source,
818
+ status: 'completed',
819
+ artifact_dir: store.artifactDir,
820
+ context: {
821
+ kind: inputContextKind,
822
+ policy_id: input.canonicalInput.context?.policy_id ?? 'fusion-session-projection-v1',
823
+ },
824
+ tool_policy: {
825
+ candidate_tools: profile.candidateTools,
826
+ evaluation_tools: [],
827
+ merge_tools: [],
828
+ },
829
+ models: store.snapshot().models,
830
+ evaluator_attempts: store
831
+ .snapshot()
832
+ .attempts.filter((attempt) => attempt.stage === 'evaluation').length,
833
+ usage,
834
+ budget: {
835
+ policy_id: FUSION_BUDGET_POLICY.id,
836
+ calibration_version: budgetPlan.policy.calibration_version,
837
+ route_table: budget.routes,
838
+ rate_sources: budget.resultRateSources,
839
+ unknown_provider_warnings: budget.unknownProviderWarnings,
840
+ calibration_warnings: calibrationWarnings,
770
841
  },
771
842
  };
843
+ await store.writeCommittedResult(mergedRef, details);
844
+ await store.transition('completed');
845
+ input.onProgress?.({ type: 'completed', runId: store.runId, artifactDir: store.artifactDir });
846
+ return { mergedText: finalMergedText, details };
772
847
  } catch (error) {
773
848
  const cancelled =
774
849
  input.signal?.aborted === true ||
@@ -842,7 +917,12 @@ export class FusionOrchestrator {
842
917
  slot,
843
918
  profile.id === 'validate' ? 'txt' : 'md',
844
919
  ).then(async (result) => {
845
- await store.recordChildAttempt({ result, systemPrompt, prompt, responseKind: profile.id === 'validate' ? 'txt' : 'md' });
920
+ await store.recordChildAttempt({
921
+ result,
922
+ systemPrompt,
923
+ prompt,
924
+ responseKind: profile.id === 'validate' ? 'txt' : 'md',
925
+ });
846
926
  await this.recordCalibrationObservation(
847
927
  input,
848
928
  store,
@@ -1054,9 +1134,7 @@ export class FusionOrchestrator {
1054
1134
  ? store.childToolCallLogPath(stage, slot, logicalAttempt)
1055
1135
  : undefined;
1056
1136
  const sourcePolicy =
1057
- capability === 'research'
1058
- ? store.sourcePolicyLaunchReference()
1059
- : undefined;
1137
+ capability === 'research' ? store.sourcePolicyLaunchReference() : undefined;
1060
1138
  try {
1061
1139
  return await this.childRunner(
1062
1140
  childOptions(
@@ -1077,7 +1155,15 @@ export class FusionOrchestrator {
1077
1155
  if (!signal.aborted && retryableSpawn(error, launchTry) && launchTry === 1) continue;
1078
1156
  addFailedChildUsage(usage, error);
1079
1157
  await store.recordFailedAttempt(
1080
- recordFailureInput(error, stage, slot, logicalAttempt, systemPrompt, userPrompt, responseKind),
1158
+ recordFailureInput(
1159
+ error,
1160
+ stage,
1161
+ slot,
1162
+ logicalAttempt,
1163
+ systemPrompt,
1164
+ userPrompt,
1165
+ responseKind,
1166
+ ),
1081
1167
  );
1082
1168
  await store.setUsage(usage);
1083
1169
  throw error;