pi-background-tasks 2.1.3 → 2.1.4

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.
@@ -1,17 +1,35 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { lstat, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { sha256Buffer } from '../attested-pi-run.js';
3
+ import { canonicalJson, sha256Buffer } from '../attested-pi-run.js';
4
4
  import { parseJsonText, type JsonObject } from '../common.js';
5
+ import {
6
+ FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
7
+ FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
8
+ FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES,
9
+ FUSION_FAILURE_SUMMARY_MAX_BYTES,
10
+ assertFusionArtifactBasename,
11
+ buildFusionRunProgress,
12
+ } from './artifacts.js';
5
13
  import {
6
14
  FUSION_COMMITTED_RESULT_SCHEMA_VERSION,
15
+ FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
16
+ FUSION_LEGACY_MANIFEST_SCHEMA_VERSION,
7
17
  FUSION_MANIFEST_SCHEMA_VERSION,
8
18
  FUSION_RESULT_SCHEMA_VERSION,
9
19
  FusionError,
10
20
  type FusionArtifactRef,
21
+ type FusionFailureAttemptMetadata,
22
+ type FusionFailureEvidenceArtifact,
23
+ type FusionFailureList,
24
+ type FusionFailureResultView,
25
+ type FusionFailureSummaryV1,
11
26
  type FusionResultDetails,
12
27
  type FusionRunResult,
13
28
  type FusionUsage,
14
29
  type FusionWorkflowId,
30
+ type FusionRunProgress,
31
+ type FusionSource,
32
+ type FusionStage,
15
33
  } from './types.js';
16
34
 
17
35
  const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
@@ -45,7 +63,10 @@ function artifactRef(value: unknown, label: string, artifactDir: string): Fusion
45
63
  const path = value['path'];
46
64
  const byteLength = value['byte_length'];
47
65
  const sha256 = value['sha256'];
48
- if (typeof path !== 'string' || path.length === 0 || path.includes('/') || path.includes('\\')) {
66
+ if (typeof path !== 'string') fail(`${label}.path is invalid`, artifactDir);
67
+ try {
68
+ assertFusionArtifactBasename(path);
69
+ } catch {
49
70
  fail(`${label}.path is invalid`, artifactDir);
50
71
  }
51
72
  if (!Number.isSafeInteger(byteLength) || Number(byteLength) < 0) {
@@ -308,6 +329,31 @@ async function readUtf8(
308
329
  return { bytes, text };
309
330
  }
310
331
 
332
+ /** Failure retrieval has an additional bounded, no-symlink evidence-file policy. */
333
+ async function readFailureUtf8(
334
+ path: string,
335
+ label: string,
336
+ artifactDir: string,
337
+ maxBytes: number,
338
+ ): Promise<{ bytes: Buffer; text: string }> {
339
+ try {
340
+ const metadata = await lstat(path);
341
+ if (!metadata.isFile() || metadata.isSymbolicLink()) {
342
+ fail(`${label} is not a regular artifact file`, artifactDir);
343
+ }
344
+ if (metadata.size > maxBytes) fail(`${label} exceeds its bounded artifact size`, artifactDir);
345
+ } catch (error) {
346
+ if (error instanceof FusionError) throw error;
347
+ fail(
348
+ `${label} is unreadable: ${error instanceof Error ? error.message : String(error)}`,
349
+ artifactDir,
350
+ );
351
+ }
352
+ const file = await readUtf8(path, label, artifactDir);
353
+ if (file.bytes.length > maxBytes) fail(`${label} exceeds its bounded artifact size`, artifactDir);
354
+ return file;
355
+ }
356
+
311
357
  export interface ReadFusionCommittedResultOptions {
312
358
  artifactDirAbs: string;
313
359
  artifactDir: string;
@@ -410,3 +456,504 @@ export async function readFusionCommittedResult(
410
456
  }
411
457
  return { mergedText: mergedFile.text, details };
412
458
  }
459
+
460
+ // The public tool adds a small task envelope and text receipt around this view.
461
+ // Keep the verified details below 8 KiB even after that model-visible envelope.
462
+ const FAILURE_VIEW_MAX_BYTES = 6 * 1024;
463
+ const FAILURE_CODES = new Set([
464
+ 'config_invalid', 'config_conflict', 'model_unavailable', 'context_capture_failed',
465
+ 'context_policy_unsupported_block', 'prompt_budget_exceeded_forecast',
466
+ 'prompt_budget_exceeded_measured', 'model_capacity_unknown', 'child_spawn_failed',
467
+ 'child_stdin_failed', 'child_event_invalid', 'child_exit_failed',
468
+ 'child_runtime_limit_exceeded', 'child_runtime_payload_invalid',
469
+ 'child_cache_policy_invalid', 'child_timeout', 'child_output_cap', 'child_cancelled',
470
+ 'evaluation_invalid', 'artifact_error', 'state_transition_invalid', 'orchestration_failed',
471
+ ]);
472
+ const FAILURE_REMEDIATION_IDS = new Set([
473
+ 'inspect_manifest_bound_evidence', 'inspect_terminal_error', 'split_or_reduce_work',
474
+ 'retry_same_route_after_operator_review',
475
+ ]);
476
+ const FAILURE_CLASSIFICATIONS = new Set([
477
+ 'complete_stage_output', 'partial_stage_output', 'oversized_original',
478
+ 'empty_rejected_output', 'evidence_only',
479
+ ]);
480
+
481
+ interface TrustedFailureManifest {
482
+ schemaVersion: string;
483
+ source: FusionSource;
484
+ state: 'failed' | 'cancelled';
485
+ usage: FusionUsage;
486
+ artifacts: Readonly<Record<string, FusionArtifactRef>>;
487
+ attempts: readonly FusionFailureAttemptMetadata[];
488
+ classifications: Readonly<Record<string, FusionFailureEvidenceArtifact['classification']>>;
489
+ error?: string | undefined;
490
+ }
491
+
492
+ function failureUnavailable(
493
+ state: 'failed' | 'cancelled',
494
+ status: 'unavailable' | 'integrity_failed',
495
+ ): FusionFailureResultView {
496
+ return {
497
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
498
+ summary_status: status,
499
+ terminal_state: state,
500
+ answer: { present: false, reason: 'run_did_not_commit' },
501
+ summary_unavailable_reason: status === 'unavailable' ? 'manifest_untrusted' : 'summary_integrity_failed',
502
+ };
503
+ }
504
+
505
+ function failureString(value: unknown, label: string): string {
506
+ if (typeof value !== 'string') throw new Error(`${label} must be a string`);
507
+ return value;
508
+ }
509
+
510
+ function failureInteger(value: unknown, label: string): number {
511
+ if (!Number.isSafeInteger(value) || Number(value) < 0)
512
+ throw new Error(`${label} must be a nonnegative integer`);
513
+ return Number(value);
514
+ }
515
+
516
+ function compareFailureText(left: string, right: string): number {
517
+ return left < right ? -1 : left > right ? 1 : 0;
518
+ }
519
+
520
+ function failureStage(value: unknown, label: string): FusionStage {
521
+ if (value === 'candidate' || value === 'evaluation' || value === 'merge') return value;
522
+ throw new Error(`${label} is invalid`);
523
+ }
524
+
525
+ function failureUsage(value: unknown, artifactDir: string): FusionUsage {
526
+ return usage(value, artifactDir);
527
+ }
528
+
529
+ function failureRef(value: unknown, label: string, artifactDir: string): FusionArtifactRef {
530
+ return artifactRef(value, label, artifactDir);
531
+ }
532
+
533
+ function sameFailureRef(left: FusionArtifactRef, right: FusionArtifactRef): boolean {
534
+ return left.path === right.path && left.byte_length === right.byte_length && left.sha256 === right.sha256;
535
+ }
536
+
537
+ function trustedFailureManifest(
538
+ value: unknown,
539
+ options: ReadFusionCommittedResultOptions,
540
+ ): TrustedFailureManifest {
541
+ if (!isRecord(value)) throw new Error('manifest must be an object');
542
+ const schemaVersion = value['schema_version'];
543
+ if (schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION && schemaVersion !== FUSION_LEGACY_MANIFEST_SCHEMA_VERSION)
544
+ throw new Error('manifest schema version mismatch');
545
+ if (value['run_id'] !== options.runId || value['workflow'] !== options.workflow)
546
+ throw new Error('manifest identity mismatch');
547
+ const state = value['state'];
548
+ if (state !== 'failed' && state !== 'cancelled') throw new Error('manifest is not failed or cancelled');
549
+ const source = value['source'];
550
+ if (source !== 'command' && source !== 'tool') throw new Error('manifest source is invalid');
551
+ const artifactsValue = value['artifacts'];
552
+ if (!isRecord(artifactsValue)) throw new Error('manifest artifacts are invalid');
553
+ const artifacts: Record<string, FusionArtifactRef> = {};
554
+ for (const [name, ref] of Object.entries(artifactsValue)) {
555
+ assertFusionArtifactBasename(name);
556
+ const checked = failureRef(ref, `manifest artifact ${name}`, options.artifactDir);
557
+ if (checked.path !== name) throw new Error('manifest artifact key/ref divergence');
558
+ artifacts[name] = checked;
559
+ }
560
+ const attemptsValue = value['attempts'];
561
+ if (!Array.isArray(attemptsValue)) throw new Error('manifest attempts are invalid');
562
+ const attempts: FusionFailureAttemptMetadata[] = [];
563
+ const classifications: Record<string, FusionFailureEvidenceArtifact['classification']> = {};
564
+ const attemptArtifact = (entry: unknown, label: string): string | undefined => {
565
+ if (entry === undefined) return undefined;
566
+ const name = failureString(entry, label);
567
+ assertFusionArtifactBasename(name);
568
+ if (artifacts[name] === undefined) throw new Error(`${label} is not manifest-bound`);
569
+ return name;
570
+ };
571
+ for (const attemptValue of attemptsValue) {
572
+ if (!isRecord(attemptValue)) throw new Error('manifest attempt is invalid');
573
+ const metadata = failureAttempt({
574
+ stage: attemptValue['stage'], slot: attemptValue['slot'], attempt: attemptValue['attempt'],
575
+ status: attemptValue['status'], child_created: attemptValue['child_created'],
576
+ });
577
+ attempts.push(metadata);
578
+ const response = attemptArtifact(attemptValue['response_path'], 'manifest response_path');
579
+ if (response !== undefined) {
580
+ const responseRef = artifacts[response];
581
+ if (responseRef === undefined) throw new Error('manifest response_path is not manifest-bound');
582
+ classifications[response] =
583
+ responseRef.byte_length === 0 && metadata.status !== 'completed'
584
+ ? 'empty_rejected_output'
585
+ : 'complete_stage_output';
586
+ }
587
+ const partial = attemptArtifact(
588
+ attemptValue['partial_response_path'],
589
+ 'manifest partial_response_path',
590
+ );
591
+ if (partial !== undefined) classifications[partial] = 'partial_stage_output';
592
+ const recovery = attemptValue['output_recovery'];
593
+ if (recovery !== undefined) {
594
+ if (!isRecord(recovery)) throw new Error('manifest output_recovery is invalid');
595
+ const original = attemptArtifact(
596
+ recovery['original_response_path'],
597
+ 'manifest output_recovery.original_response_path',
598
+ );
599
+ if (original === undefined) throw new Error('manifest output recovery has no original response');
600
+ classifications[original] = 'oversized_original';
601
+ }
602
+ }
603
+ attempts.sort((left, right) =>
604
+ compareFailureText(left.stage, right.stage) ||
605
+ (left.slot ?? 0) - (right.slot ?? 0) ||
606
+ left.attempt - right.attempt,
607
+ );
608
+ const manifestUsage = failureUsage(value['usage'], options.artifactDir);
609
+ const error = value['error'];
610
+ if (error !== undefined && typeof error !== 'string') throw new Error('manifest error is invalid');
611
+ return { schemaVersion, source, state, usage: manifestUsage, artifacts, attempts, classifications, ...(error === undefined ? {} : { error }) };
612
+ }
613
+
614
+ function failureMessage(value: unknown): FusionFailureSummaryV1['failure']['message'] {
615
+ if (!isRecord(value)) throw new Error('failure message is invalid');
616
+ assertOnlyKeys(value, ['byte_length', 'sha256', 'inline_message', 'omission_reason'], 'failure message', 'failure-summary.json');
617
+ const byteLength = failureInteger(value['byte_length'], 'failure message byte_length');
618
+ const sha256 = failureString(value['sha256'], 'failure message sha256');
619
+ if (!SHA256_PATTERN.test(sha256)) throw new Error('failure message sha256 is invalid');
620
+ const inline = value['inline_message'];
621
+ const omission = value['omission_reason'];
622
+ if ((inline === undefined) === (omission === undefined)) throw new Error('failure message must have exactly one representation');
623
+ if (inline !== undefined) {
624
+ if (
625
+ typeof inline !== 'string' ||
626
+ byteLength > FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES ||
627
+ Buffer.byteLength(inline, 'utf8') !== byteLength ||
628
+ sha256Buffer(Buffer.from(inline, 'utf8')) !== sha256
629
+ ) {
630
+ throw new Error('failure inline message does not match its metadata');
631
+ }
632
+ return { byte_length: byteLength, sha256, inline_message: inline };
633
+ }
634
+ if (omission !== 'exceeds_inline_message_bytes_cap')
635
+ throw new Error('failure message omission reason is invalid');
636
+ return { byte_length: byteLength, sha256, omission_reason: omission };
637
+ }
638
+
639
+ function failureList<T>(
640
+ value: unknown,
641
+ label: string,
642
+ cap: number,
643
+ parseEntry: (entry: unknown) => T,
644
+ ): FusionFailureList<T> {
645
+ if (!isRecord(value)) throw new Error(`${label} is invalid`);
646
+ assertOnlyKeys(value, ['listed', 'omitted_count'], label, 'failure-summary.json');
647
+ if (!Array.isArray(value['listed']) || value['listed'].length > cap)
648
+ throw new Error(`${label}.listed is invalid`);
649
+ return {
650
+ listed: value['listed'].map(parseEntry),
651
+ omitted_count: failureInteger(value['omitted_count'], `${label}.omitted_count`),
652
+ };
653
+ }
654
+
655
+ function failureAttempt(value: unknown): FusionFailureAttemptMetadata {
656
+ if (!isRecord(value)) throw new Error('failure attempt is invalid');
657
+ assertOnlyKeys(value, ['stage', 'slot', 'attempt', 'status', 'child_created'], 'failure attempt', 'failure-summary.json');
658
+ const stage = failureStage(value['stage'], 'failure attempt stage');
659
+ const slot = value['slot'];
660
+ if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3)
661
+ throw new Error('failure attempt slot is invalid');
662
+ if ((stage === 'candidate') !== (slot !== undefined))
663
+ throw new Error('failure attempt stage/slot is inconsistent');
664
+ const status = value['status'];
665
+ if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') throw new Error('failure attempt status is invalid');
666
+ if (typeof value['child_created'] !== 'boolean') throw new Error('failure attempt child_created is invalid');
667
+ const attempt = failureInteger(value['attempt'], 'failure attempt number');
668
+ if (attempt === 0) throw new Error('failure attempt number must be positive');
669
+ return { stage, ...(slot === undefined ? {} : { slot }), attempt, status, child_created: value['child_created'] };
670
+ }
671
+
672
+ function failureEvidence(
673
+ value: unknown,
674
+ manifest: TrustedFailureManifest,
675
+ artifactDir: string,
676
+ ): FusionFailureEvidenceArtifact {
677
+ if (!isRecord(value)) throw new Error('failure evidence row is invalid');
678
+ assertOnlyKeys(value, ['name', 'classification', 'ref'], 'failure evidence row', artifactDir);
679
+ const name = failureString(value['name'], 'failure evidence name');
680
+ assertFusionArtifactBasename(name);
681
+ const classification = value['classification'];
682
+ if (typeof classification !== 'string' || !FAILURE_CLASSIFICATIONS.has(classification))
683
+ throw new Error('failure evidence classification is invalid');
684
+ const ref = failureRef(value['ref'], 'failure evidence ref', artifactDir);
685
+ const manifestRef = manifest.artifacts[name];
686
+ if (manifestRef === undefined || !sameFailureRef(ref, manifestRef))
687
+ throw new Error('failure evidence ref diverges from manifest');
688
+ const expectedClassification = manifest.classifications[name] ?? 'evidence_only';
689
+ if (classification !== expectedClassification) throw new Error('failure evidence classification diverges from manifest');
690
+ return { name, classification: expectedClassification, ref };
691
+ }
692
+
693
+ function failureProgress(value: unknown, manifest: TrustedFailureManifest, artifactDir: string): FusionRunProgress {
694
+ if (!isRecord(value)) throw new Error('failure progress is invalid');
695
+ assertOnlyKeys(value, ['manifest_state', 'candidates', 'evaluation', 'merge', 'usage_so_far'], 'failure progress', artifactDir);
696
+ if (value['manifest_state'] !== manifest.state) throw new Error('failure progress state diverges from manifest');
697
+ const stage = (entry: unknown, label: string, candidates: boolean): FusionRunProgress['candidates'] => {
698
+ if (!isRecord(entry)) throw new Error(`${label} is invalid`);
699
+ assertOnlyKeys(entry, ['status', 'attempts_recorded', 'children_created', 'children_completed', 'children_failed', 'children_cancelled', 'not_started_slots'], label, artifactDir);
700
+ const status = entry['status'];
701
+ if (status !== 'not_started' && status !== 'incomplete' && status !== 'completed') throw new Error(`${label}.status is invalid`);
702
+ const notStarted = entry['not_started_slots'];
703
+ if (candidates ? !Number.isSafeInteger(notStarted) || Number(notStarted) < 0 || Number(notStarted) > 3 : notStarted !== undefined)
704
+ throw new Error(`${label}.not_started_slots is invalid`);
705
+ return {
706
+ status,
707
+ attempts_recorded: failureInteger(entry['attempts_recorded'], `${label}.attempts_recorded`),
708
+ children_created: failureInteger(entry['children_created'], `${label}.children_created`),
709
+ children_completed: failureInteger(entry['children_completed'], `${label}.children_completed`),
710
+ children_failed: failureInteger(entry['children_failed'], `${label}.children_failed`),
711
+ children_cancelled: failureInteger(entry['children_cancelled'], `${label}.children_cancelled`),
712
+ ...(candidates ? { not_started_slots: Number(notStarted) } : {}),
713
+ };
714
+ };
715
+ const usageSoFar = failureUsage(value['usage_so_far'], artifactDir);
716
+ if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage)) throw new Error('failure progress usage diverges from manifest');
717
+ return { manifest_state: manifest.state, candidates: stage(value['candidates'], 'failure candidates', true), evaluation: stage(value['evaluation'], 'failure evaluation', false), merge: stage(value['merge'], 'failure merge', false), usage_so_far: usageSoFar };
718
+ }
719
+
720
+ function parseFailureSummary(
721
+ value: unknown,
722
+ manifest: TrustedFailureManifest,
723
+ options: ReadFusionCommittedResultOptions,
724
+ ): FusionFailureSummaryV1 {
725
+ if (!isRecord(value)) throw new Error('failure summary must be an object');
726
+ assertOnlyKeys(value, ['schema_version', 'run_id', 'workflow', 'source', 'terminal_state', 'created_at', 'answer', 'failure', 'progress', 'usage_so_far', 'attempts', 'evidence_artifacts', 'remediation_ids'], 'failure summary', options.artifactDir);
727
+ if (value['schema_version'] !== FUSION_FAILURE_SUMMARY_SCHEMA_VERSION || value['run_id'] !== options.runId || value['workflow'] !== options.workflow || value['source'] !== manifest.source || value['terminal_state'] !== manifest.state || typeof value['created_at'] !== 'string')
728
+ throw new Error('failure summary identity is invalid');
729
+ const answer = value['answer'];
730
+ if (!isRecord(answer) || answer['present'] !== false || answer['reason'] !== 'run_did_not_commit') throw new Error('failure summary answer assertion is invalid');
731
+ const failure = value['failure'];
732
+ if (!isRecord(failure)) throw new Error('failure summary failure metadata is invalid');
733
+ assertOnlyKeys(failure, ['code', 'stage', 'slot', 'attempt', 'child_created', 'message'], 'failure metadata', options.artifactDir);
734
+ const code = failure['code'];
735
+ if (code !== null && (typeof code !== 'string' || !FAILURE_CODES.has(code))) throw new Error('failure code is invalid');
736
+ const stageValue = failure['stage'];
737
+ const stage = stageValue === undefined ? undefined : failureStage(stageValue, 'failure stage');
738
+ const slot = failure['slot'];
739
+ if (slot !== undefined && slot !== 1 && slot !== 2 && slot !== 3) throw new Error('failure slot is invalid');
740
+ if (failure['attempt'] !== undefined) failureInteger(failure['attempt'], 'failure attempt');
741
+ if (typeof failure['child_created'] !== 'boolean') throw new Error('failure child_created is invalid');
742
+ const progress = failureProgress(value['progress'], manifest, options.artifactDir);
743
+ const expectedProgress = buildFusionRunProgress(manifest);
744
+ if (canonicalJson(progress) !== canonicalJson(expectedProgress))
745
+ throw new Error('failure progress diverges from durable attempts');
746
+ const usageSoFar = failureUsage(value['usage_so_far'], options.artifactDir);
747
+ if (canonicalJson(usageSoFar) !== canonicalJson(manifest.usage))
748
+ throw new Error('summary usage diverges from manifest');
749
+ const attempts = failureList(
750
+ value['attempts'],
751
+ 'failure attempts',
752
+ FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
753
+ failureAttempt,
754
+ );
755
+ const expectedAttempts = manifest.attempts;
756
+ const expectedAttemptListedCount = Math.min(
757
+ expectedAttempts.length,
758
+ FUSION_FAILURE_SUMMARY_ATTEMPT_CAP,
759
+ );
760
+ if (
761
+ attempts.listed.length !== expectedAttemptListedCount ||
762
+ attempts.omitted_count !== expectedAttempts.length - expectedAttemptListedCount ||
763
+ canonicalJson(attempts.listed) !==
764
+ canonicalJson(
765
+ expectedAttempts.filter((_attempt, index) => index < expectedAttemptListedCount),
766
+ )
767
+ ) {
768
+ throw new Error('failure attempt metadata diverges from manifest');
769
+ }
770
+ const evidence = failureList(
771
+ value['evidence_artifacts'],
772
+ 'failure evidence artifacts',
773
+ FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
774
+ (entry) => failureEvidence(entry, manifest, options.artifactDir),
775
+ );
776
+ const expectedEvidence = Object.entries(manifest.artifacts)
777
+ .filter(([name]) => name !== 'failure-summary.json')
778
+ .map(([name, ref]) => ({ name, classification: manifest.classifications[name] ?? 'evidence_only', ref }))
779
+ .sort((left, right) => compareFailureText(left.name, right.name));
780
+ const expectedEvidenceListedCount = Math.min(
781
+ expectedEvidence.length,
782
+ FUSION_FAILURE_SUMMARY_EVIDENCE_CAP,
783
+ );
784
+ if (
785
+ evidence.listed.length !== expectedEvidenceListedCount ||
786
+ evidence.omitted_count !== expectedEvidence.length - expectedEvidenceListedCount ||
787
+ canonicalJson(evidence.listed) !==
788
+ canonicalJson(
789
+ expectedEvidence.filter((_evidence, index) => index < expectedEvidenceListedCount),
790
+ )
791
+ ) {
792
+ throw new Error('failure evidence metadata diverges from manifest');
793
+ }
794
+ const remediationIds = value['remediation_ids'];
795
+ const expectedRemediationIds = [
796
+ 'inspect_manifest_bound_evidence',
797
+ 'inspect_terminal_error',
798
+ 'split_or_reduce_work',
799
+ 'retry_same_route_after_operator_review',
800
+ ];
801
+ if (
802
+ !Array.isArray(remediationIds) ||
803
+ !remediationIds.every((id) => typeof id === 'string' && FAILURE_REMEDIATION_IDS.has(id)) ||
804
+ canonicalJson(remediationIds) !== canonicalJson(expectedRemediationIds)
805
+ ) {
806
+ throw new Error('failure remediation ids are invalid');
807
+ }
808
+ const terminalMessage = failureMessage(failure['message']);
809
+ if (manifest.error === undefined) throw new Error('manifest terminal error is unavailable');
810
+ const manifestErrorBytes = Buffer.from(manifest.error, 'utf8');
811
+ const expectedMessage = {
812
+ byte_length: manifestErrorBytes.length,
813
+ sha256: sha256Buffer(manifestErrorBytes),
814
+ ...(manifestErrorBytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
815
+ ? { inline_message: manifest.error }
816
+ : { omission_reason: 'exceeds_inline_message_bytes_cap' as const }),
817
+ };
818
+ if (canonicalJson(terminalMessage) !== canonicalJson(expectedMessage))
819
+ throw new Error('failure message diverges from manifest terminal error');
820
+ return {
821
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION, run_id: options.runId, workflow: options.workflow,
822
+ source: manifest.source, terminal_state: manifest.state, created_at: value['created_at'],
823
+ answer: { present: false, reason: 'run_did_not_commit' },
824
+ failure: { code: code as FusionFailureSummaryV1['failure']['code'], ...(stage === undefined ? {} : { stage }), ...(slot === undefined ? {} : { slot }), ...(failure['attempt'] === undefined ? {} : { attempt: Number(failure['attempt']) }), child_created: failure['child_created'], message: terminalMessage },
825
+ progress, usage_so_far: usageSoFar, attempts, evidence_artifacts: evidence,
826
+ remediation_ids: [...remediationIds] as FusionFailureSummaryV1['remediation_ids'],
827
+ };
828
+ }
829
+
830
+ interface FailureViewSource {
831
+ terminal_state: Exclude<FusionFailureResultView['terminal_state'], 'completed'>;
832
+ failure?: FusionFailureResultView['failure'] | undefined;
833
+ progress: FusionRunProgress;
834
+ usage_so_far: FusionUsage;
835
+ attempts: FusionFailureList<FusionFailureAttemptMetadata>;
836
+ evidence_artifacts: FusionFailureList<FusionFailureEvidenceArtifact>;
837
+ remediation_ids: FusionFailureSummaryV1['remediation_ids'];
838
+ }
839
+
840
+ function boundedFailureView(
841
+ source: FailureViewSource,
842
+ status: FusionFailureResultView['summary_status'],
843
+ summaryRef?: FusionArtifactRef,
844
+ ): FusionFailureResultView {
845
+ const attempts = { listed: [...source.attempts.listed], omitted_count: source.attempts.omitted_count };
846
+ const evidence = { listed: [...source.evidence_artifacts.listed], omitted_count: source.evidence_artifacts.omitted_count };
847
+ const failure = source.failure;
848
+ const view: FusionFailureResultView = {
849
+ schema_version: FUSION_FAILURE_SUMMARY_SCHEMA_VERSION,
850
+ summary_status: status,
851
+ terminal_state: source.terminal_state,
852
+ answer: { present: false, reason: 'run_did_not_commit' },
853
+ ...(failure === undefined
854
+ ? {}
855
+ : { failure: { ...failure, message: { ...failure.message } } }),
856
+ progress: source.progress,
857
+ usage_so_far: source.usage_so_far,
858
+ attempts,
859
+ evidence_artifacts: evidence,
860
+ remediation_ids: source.remediation_ids,
861
+ ...(summaryRef === undefined ? {} : { failure_summary_ref: summaryRef }),
862
+ };
863
+ const fits = (): boolean => Buffer.byteLength(canonicalJson(view), 'utf8') <= FAILURE_VIEW_MAX_BYTES;
864
+ if (!fits() && view.failure?.message.inline_message !== undefined) {
865
+ const message = view.failure.message;
866
+ view.failure = {
867
+ ...view.failure,
868
+ message: {
869
+ byte_length: message.byte_length,
870
+ sha256: message.sha256,
871
+ omission_reason: 'result_view_byte_budget',
872
+ },
873
+ };
874
+ }
875
+ while (!fits() && evidence.listed.length > 0) { evidence.listed.pop(); evidence.omitted_count += 1; }
876
+ while (!fits() && attempts.listed.length > 0) { attempts.listed.pop(); attempts.omitted_count += 1; }
877
+ if (!fits()) throw new Error('failure result view exceeds its byte budget without a safe whole-section omission');
878
+ return view;
879
+ }
880
+
881
+ function legacyFailureSource(
882
+ manifest: TrustedFailureManifest,
883
+ ): FailureViewSource {
884
+ const message =
885
+ manifest.error === undefined
886
+ ? undefined
887
+ : (() => {
888
+ const bytes = Buffer.from(manifest.error, 'utf8');
889
+ return bytes.length <= FUSION_FAILURE_SUMMARY_INLINE_MESSAGE_BYTES
890
+ ? {
891
+ byte_length: bytes.length,
892
+ sha256: sha256Buffer(bytes),
893
+ inline_message: manifest.error,
894
+ }
895
+ : {
896
+ byte_length: bytes.length,
897
+ sha256: sha256Buffer(bytes),
898
+ omission_reason: 'exceeds_inline_message_bytes_cap' as const,
899
+ };
900
+ })();
901
+ const evidence = Object.entries(manifest.artifacts)
902
+ .map(([name, ref]) => ({
903
+ name,
904
+ classification: manifest.classifications[name] ?? 'evidence_only',
905
+ ref: { ...ref },
906
+ }))
907
+ .sort((left, right) => compareFailureText(left.name, right.name));
908
+ const attempts = manifest.attempts;
909
+ return {
910
+ terminal_state: manifest.state,
911
+ ...(message === undefined ? {} : { failure: { message } }),
912
+ progress: buildFusionRunProgress(manifest),
913
+ usage_so_far: manifest.usage,
914
+ attempts: {
915
+ listed: attempts.filter((_attempt, index) => index < FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
916
+ omitted_count: attempts.length - Math.min(attempts.length, FUSION_FAILURE_SUMMARY_ATTEMPT_CAP),
917
+ },
918
+ evidence_artifacts: {
919
+ listed: evidence.filter((_evidence, index) => index < FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
920
+ omitted_count: evidence.length - Math.min(evidence.length, FUSION_FAILURE_SUMMARY_EVIDENCE_CAP),
921
+ },
922
+ remediation_ids: ['inspect_manifest_bound_evidence', 'inspect_terminal_error'],
923
+ };
924
+ }
925
+
926
+ /** Read only terminal failure metadata; it never reads stage-output bodies. */
927
+ export async function readFusionFailureResult(
928
+ options: ReadFusionCommittedResultOptions,
929
+ ): Promise<FusionFailureResultView> {
930
+ let manifest: TrustedFailureManifest;
931
+ try {
932
+ const file = await readUtf8(join(options.artifactDirAbs, 'manifest.json'), 'manifest.json', options.artifactDir);
933
+ manifest = trustedFailureManifest(parseJsonText(file.text), options);
934
+ } catch {
935
+ return failureUnavailable('failed', 'unavailable');
936
+ }
937
+ const summaryRef = manifest.artifacts['failure-summary.json'];
938
+ if (summaryRef === undefined) {
939
+ return boundedFailureView(legacyFailureSource(manifest), 'legacy_manifest_only');
940
+ }
941
+ if (manifest.schemaVersion !== FUSION_MANIFEST_SCHEMA_VERSION || summaryRef.path !== 'failure-summary.json')
942
+ return failureUnavailable(manifest.state, 'integrity_failed');
943
+ try {
944
+ if (summaryRef.byte_length > FUSION_FAILURE_SUMMARY_MAX_BYTES)
945
+ throw new Error('failure summary exceeds its bounded artifact size');
946
+ const file = await readFailureUtf8(
947
+ join(options.artifactDirAbs, summaryRef.path),
948
+ 'failure-summary.json',
949
+ options.artifactDir,
950
+ FUSION_FAILURE_SUMMARY_MAX_BYTES,
951
+ );
952
+ if (file.bytes.length !== summaryRef.byte_length || sha256Buffer(file.bytes) !== summaryRef.sha256)
953
+ throw new Error('failure summary hash/length mismatch');
954
+ const summary = parseFailureSummary(parseJsonText(file.text), manifest, options);
955
+ return boundedFailureView(summary, 'verified', summaryRef);
956
+ } catch {
957
+ return failureUnavailable(manifest.state, 'integrity_failed');
958
+ }
959
+ }