@alexeiled/pi-fusion 0.7.0 → 0.8.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.
package/src/run-store.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type {
3
3
  FusionPhase,
4
+ FusionProfileSnapshot,
5
+ FusionRecoveryState,
4
6
  FusionRun,
5
7
  ModelAttempt,
6
8
  PanelDecision,
@@ -25,6 +27,9 @@ export type FusionRunSummary = Omit<
25
27
  | "profileName"
26
28
  | "operationId"
27
29
  | "outputContract"
30
+ | "completionQuality"
31
+ | "effectiveTimeouts"
32
+ | "recovery"
28
33
  | "phase"
29
34
  | "createdAt"
30
35
  | "updatedAt"
@@ -45,6 +50,10 @@ export interface FusionRunStartInput {
45
50
  baseProfileName?: string;
46
51
  operationId?: string;
47
52
  outputContract?: FusionRun["outputContract"];
53
+ minimumSuccessfulPanelists?: FusionRun["minimumSuccessfulPanelists"];
54
+ profileSnapshot?: FusionRun["profileSnapshot"];
55
+ timeoutOverrides?: FusionRun["timeoutOverrides"];
56
+ effectiveTimeouts?: FusionRun["effectiveTimeouts"];
48
57
  phase?: Exclude<FusionPhase, FusionTerminalPhase>;
49
58
  createdAt?: number;
50
59
  }
@@ -60,8 +69,12 @@ export interface FusionRunPatch {
60
69
  judgeRunId?: string;
61
70
  judgeAsyncDir?: string;
62
71
  judgeObservation?: FusionRun["judgeObservation"];
72
+ completionQuality?: FusionRun["completionQuality"];
63
73
  panelOutputs?: FusionRun["panelOutputs"];
64
74
  panelFailures?: FusionRun["panelFailures"];
75
+ recovery?: FusionRun["recovery"];
76
+ /** `null` clears an intent once the RPC returned its durable remote ID. */
77
+ spawnIntent?: FusionRun["spawnIntent"] | null;
65
78
  report?: string;
66
79
  error?: string;
67
80
  updatedAt?: number;
@@ -71,6 +84,8 @@ export interface FusionRunTransitionPatch {
71
84
  chainRunId?: string;
72
85
  panelRunId?: string;
73
86
  judgeRunId?: string;
87
+ recovery?: FusionRun["recovery"];
88
+ spawnIntent?: FusionRun["spawnIntent"];
74
89
  report?: string;
75
90
  error?: string;
76
91
  updatedAt?: number;
@@ -107,6 +122,7 @@ export class FusionRunStore {
107
122
  private readonly now: () => number;
108
123
  private readonly idFactory: () => string;
109
124
  private readonly persistence: FusionRunStorePersistence | undefined;
125
+ private restoreError: string | undefined;
110
126
 
111
127
  constructor(options: FusionRunStoreOptions = {}) {
112
128
  this.now = options.now ?? Date.now;
@@ -134,6 +150,11 @@ export class FusionRunStore {
134
150
  return runId ? this.getRunById(runId) : undefined;
135
151
  }
136
152
 
153
+ /** A corrupt newest snapshot must never revive an older active run. */
154
+ getRestoreError(): string | undefined {
155
+ return this.restoreError;
156
+ }
157
+
137
158
  startRun(input: FusionRunStartInput): FusionRun {
138
159
  if (this.activeRun) {
139
160
  throw new FusionRunStoreError(
@@ -167,13 +188,25 @@ export class FusionRunStore {
167
188
  ...(input.outputContract !== undefined
168
189
  ? { outputContract: input.outputContract }
169
190
  : {}),
191
+ ...(input.minimumSuccessfulPanelists !== undefined
192
+ ? { minimumSuccessfulPanelists: input.minimumSuccessfulPanelists }
193
+ : {}),
194
+ ...(input.profileSnapshot !== undefined
195
+ ? { profileSnapshot: cloneProfileSnapshot(input.profileSnapshot) }
196
+ : {}),
197
+ ...(input.timeoutOverrides !== undefined
198
+ ? { timeoutOverrides: { ...input.timeoutOverrides } }
199
+ : {}),
200
+ ...(input.effectiveTimeouts !== undefined
201
+ ? { effectiveTimeouts: { ...input.effectiveTimeouts } }
202
+ : {}),
170
203
  phase: input.phase ?? "chain",
171
204
  createdAt,
172
205
  updatedAt: createdAt,
173
206
  };
207
+ this.persistRun(run);
174
208
  this.activeRun = run;
175
209
  this.rememberRun(run);
176
- this.persistRun(run);
177
210
  return cloneRun(run);
178
211
  }
179
212
 
@@ -221,11 +254,26 @@ export class FusionRunStore {
221
254
  restoreFromEntries(
222
255
  entries: readonly unknown[],
223
256
  ): FusionRunSummary | undefined {
224
- const states = readFusionRunStates(entries);
257
+ this.restoreError = undefined;
225
258
  this.runsById.clear();
226
259
  this.runIdsByOperationId.clear();
227
- for (const state of states) this.rememberRun(state);
228
260
 
261
+ const latestPersisted = lastFusionRunEnvelope(entries);
262
+ if (
263
+ latestPersisted &&
264
+ (!isFusionRunEntry(latestPersisted) || !isFusionRunState(latestPersisted.data))
265
+ ) {
266
+ // History readers may skip malformed entries, but restore must not adopt
267
+ // an older active state after a newer snapshot was corrupted.
268
+ this.activeRun = undefined;
269
+ this.lastRunSummary = undefined;
270
+ this.restoreError =
271
+ "Latest persisted fusion run snapshot is invalid; refusing stale active-run recovery.";
272
+ return undefined;
273
+ }
274
+
275
+ const states = readFusionRunStates(entries);
276
+ for (const state of states) this.rememberRun(state);
229
277
  const latestState = states.at(-1);
230
278
  const summary = readLastFusionRunSummary(entries);
231
279
  this.activeRun =
@@ -346,12 +394,20 @@ function applyPatch(
346
394
  if (patch.judgeObservation !== undefined) {
347
395
  updated.judgeObservation = cloneObservation(patch.judgeObservation);
348
396
  }
397
+ if (patch.completionQuality !== undefined) {
398
+ updated.completionQuality = patch.completionQuality;
399
+ }
349
400
  if (patch.panelOutputs !== undefined) {
350
401
  updated.panelOutputs = clonePanelOutputs(patch.panelOutputs);
351
402
  }
352
403
  if (patch.panelFailures !== undefined) {
353
404
  updated.panelFailures = clonePanelFailures(patch.panelFailures);
354
405
  }
406
+ if (patch.recovery !== undefined) updated.recovery = cloneRecovery(patch.recovery);
407
+ if (patch.spawnIntent === null) delete updated.spawnIntent;
408
+ else if (patch.spawnIntent !== undefined) {
409
+ updated.spawnIntent = cloneSpawnIntent(patch.spawnIntent);
410
+ }
355
411
  if (patch.report !== undefined) updated.report = patch.report;
356
412
  if (patch.error !== undefined) updated.error = patch.error;
357
413
  return updated;
@@ -371,6 +427,10 @@ function applyTransitionPatch(
371
427
  if (patch.chainRunId !== undefined) updated.chainRunId = patch.chainRunId;
372
428
  if (patch.panelRunId !== undefined) updated.panelRunId = patch.panelRunId;
373
429
  if (patch.judgeRunId !== undefined) updated.judgeRunId = patch.judgeRunId;
430
+ if (patch.recovery !== undefined) updated.recovery = cloneRecovery(patch.recovery);
431
+ if (patch.spawnIntent !== undefined) {
432
+ updated.spawnIntent = cloneSpawnIntent(patch.spawnIntent);
433
+ }
374
434
  if (patch.report !== undefined) updated.report = patch.report;
375
435
  if (patch.error !== undefined) updated.error = patch.error;
376
436
  return updated;
@@ -387,6 +447,13 @@ function toRunSummary(
387
447
  ...(run.outputContract !== undefined
388
448
  ? { outputContract: run.outputContract }
389
449
  : {}),
450
+ ...(run.completionQuality !== undefined
451
+ ? { completionQuality: run.completionQuality }
452
+ : {}),
453
+ ...(run.effectiveTimeouts !== undefined
454
+ ? { effectiveTimeouts: { ...run.effectiveTimeouts } }
455
+ : {}),
456
+ ...(run.recovery !== undefined ? { recovery: cloneRecovery(run.recovery) } : {}),
390
457
  phase: run.phase,
391
458
  createdAt: run.createdAt,
392
459
  updatedAt: run.updatedAt,
@@ -415,6 +482,21 @@ function cloneRun(run: FusionRun): FusionRun {
415
482
  ...(run.outputContract !== undefined
416
483
  ? { outputContract: run.outputContract }
417
484
  : {}),
485
+ ...(run.minimumSuccessfulPanelists !== undefined
486
+ ? { minimumSuccessfulPanelists: run.minimumSuccessfulPanelists }
487
+ : {}),
488
+ ...(run.profileSnapshot !== undefined
489
+ ? { profileSnapshot: cloneProfileSnapshot(run.profileSnapshot) }
490
+ : {}),
491
+ ...(run.timeoutOverrides !== undefined
492
+ ? { timeoutOverrides: { ...run.timeoutOverrides } }
493
+ : {}),
494
+ ...(run.effectiveTimeouts !== undefined
495
+ ? { effectiveTimeouts: { ...run.effectiveTimeouts } }
496
+ : {}),
497
+ ...(run.completionQuality !== undefined
498
+ ? { completionQuality: run.completionQuality }
499
+ : {}),
418
500
  phase: run.phase,
419
501
  createdAt: run.createdAt,
420
502
  updatedAt: run.updatedAt,
@@ -445,6 +527,10 @@ function cloneRun(run: FusionRun): FusionRun {
445
527
  ...(run.panelFailures !== undefined
446
528
  ? { panelFailures: clonePanelFailures(run.panelFailures) }
447
529
  : {}),
530
+ ...(run.recovery !== undefined ? { recovery: cloneRecovery(run.recovery) } : {}),
531
+ ...(run.spawnIntent !== undefined
532
+ ? { spawnIntent: cloneSpawnIntent(run.spawnIntent) }
533
+ : {}),
448
534
  ...(run.report !== undefined ? { report: run.report } : {}),
449
535
  ...(run.error !== undefined ? { error: run.error } : {}),
450
536
  };
@@ -454,17 +540,30 @@ function cloneRunSummary(summary: FusionRunSummary): FusionRunSummary {
454
540
  return toRunSummary(summary);
455
541
  }
456
542
 
457
- function isFusionRunEntry(
458
- value: unknown,
459
- ): value is { type: "custom"; customType: string; data: unknown } {
543
+ function lastFusionRunEnvelope(entries: readonly unknown[]): unknown {
544
+ for (let index = entries.length - 1; index >= 0; index--) {
545
+ const entry = entries[index];
546
+ // Locate the newest entry by its envelope before inspecting data. A
547
+ // malformed data field (or its absence) must not revive an older run.
548
+ if (isFusionRunEnvelope(entry)) return entry;
549
+ }
550
+ return undefined;
551
+ }
552
+
553
+ function isFusionRunEnvelope(value: unknown): value is Record<string, unknown> {
460
554
  return (
461
555
  isRecord(value) &&
462
556
  value.type === "custom" &&
463
- value.customType === FUSION_RUN_ENTRY_TYPE &&
464
- "data" in value
557
+ value.customType === FUSION_RUN_ENTRY_TYPE
465
558
  );
466
559
  }
467
560
 
561
+ function isFusionRunEntry(
562
+ value: unknown,
563
+ ): value is { type: "custom"; customType: string; data: unknown } {
564
+ return isFusionRunEnvelope(value) && "data" in value;
565
+ }
566
+
468
567
  function isFusionRunState(value: unknown): value is FusionRun {
469
568
  if (!isRecord(value)) return false;
470
569
  if (!isNonEmptyString(value.id)) return false;
@@ -479,6 +578,47 @@ function isFusionRunState(value: unknown): value is FusionRun {
479
578
  ) {
480
579
  return false;
481
580
  }
581
+ if (
582
+ value.minimumSuccessfulPanelists !== undefined &&
583
+ value.minimumSuccessfulPanelists !== "majority" &&
584
+ value.minimumSuccessfulPanelists !== "all" &&
585
+ (!isFiniteNumber(value.minimumSuccessfulPanelists) ||
586
+ !Number.isInteger(value.minimumSuccessfulPanelists) ||
587
+ value.minimumSuccessfulPanelists < 1)
588
+ ) {
589
+ return false;
590
+ }
591
+ if (
592
+ value.profileSnapshot !== undefined &&
593
+ !isProfileSnapshot(value.profileSnapshot)
594
+ ) {
595
+ return false;
596
+ }
597
+ // Snapshots are the canonical quorum record for new runs. Older records may
598
+ // also carry the run-level policy, but it must resolve to the same quorum.
599
+ if (
600
+ value.profileSnapshot !== undefined &&
601
+ value.minimumSuccessfulPanelists !== undefined &&
602
+ resolvePersistedQuorum(
603
+ value.minimumSuccessfulPanelists,
604
+ value.profileSnapshot.panel.length,
605
+ ) !== value.profileSnapshot.minimumSuccessfulPanelists
606
+ ) {
607
+ return false;
608
+ }
609
+ if (value.timeoutOverrides !== undefined && !isTimeoutOverrides(value.timeoutOverrides)) {
610
+ return false;
611
+ }
612
+ if (value.effectiveTimeouts !== undefined && !isEffectiveTimeouts(value.effectiveTimeouts)) {
613
+ return false;
614
+ }
615
+ if (
616
+ value.completionQuality !== undefined &&
617
+ value.completionQuality !== "complete" &&
618
+ value.completionQuality !== "partial"
619
+ ) {
620
+ return false;
621
+ }
482
622
  if (
483
623
  value.inlinePanel !== undefined &&
484
624
  (!Array.isArray(value.inlinePanel) ||
@@ -556,19 +696,51 @@ function isFusionRunState(value: unknown): value is FusionRun {
556
696
  ) {
557
697
  return false;
558
698
  }
699
+ if (value.recovery !== undefined && !isRecoveryState(value.recovery)) {
700
+ return false;
701
+ }
702
+ if (value.spawnIntent !== undefined && !isSpawnIntent(value.spawnIntent)) {
703
+ return false;
704
+ }
559
705
  if (value.report !== undefined && typeof value.report !== "string") {
560
706
  return false;
561
707
  }
562
708
  if (value.error !== undefined && typeof value.error !== "string") {
563
709
  return false;
564
710
  }
565
- return true;
711
+ return (
712
+ value.profileSnapshot === undefined ||
713
+ validateFusionRunPanelSlots(value, value.profileSnapshot.panel.length) ===
714
+ undefined
715
+ );
566
716
  }
567
717
 
568
718
  function isFusionRunSummary(value: unknown): value is FusionRunSummary {
569
719
  return isFusionRunState(value) && isTerminalPhase(value.phase);
570
720
  }
571
721
 
722
+ function isTimeoutOverrides(value: unknown): boolean {
723
+ if (!isRecord(value)) return false;
724
+ return [
725
+ value.panelistTimeoutMs,
726
+ value.panelTimeoutMs,
727
+ value.panelGraceMs,
728
+ value.judgeTimeoutMs,
729
+ ].every((timeout) => timeout === undefined || (isFiniteNumber(timeout) && Number.isInteger(timeout) && timeout > 0));
730
+ }
731
+
732
+ function isEffectiveTimeouts(value: unknown): boolean {
733
+ return (
734
+ isTimeoutOverrides(value) &&
735
+ isRecord(value) &&
736
+ isFiniteNumber(value.panelistTimeoutMs) &&
737
+ isFiniteNumber(value.panelTimeoutMs) &&
738
+ isFiniteNumber(value.panelGraceMs) &&
739
+ isFiniteNumber(value.judgeTimeoutMs) &&
740
+ typeof value.usesLegacyTimeout === "boolean"
741
+ );
742
+ }
743
+
572
744
  function isFusionPhase(value: unknown): value is FusionPhase {
573
745
  return (
574
746
  value === "panel" ||
@@ -584,6 +756,45 @@ function isTerminalPhase(value: unknown): value is FusionTerminalPhase {
584
756
  return value === "done" || value === "failed" || value === "cancelled";
585
757
  }
586
758
 
759
+ /**
760
+ * Checks persisted slot references before they are restored or merged. Legacy
761
+ * runs omit a profile snapshot, so their caller supplies the safely resolved
762
+ * profile length during restore.
763
+ */
764
+ export function validateFusionRunPanelSlots(
765
+ run: Pick<
766
+ FusionRun,
767
+ "panelOutputs" | "panelFailures" | "panelStoppedIndices" | "recovery"
768
+ >,
769
+ panelLength: number,
770
+ ): string | undefined {
771
+ const slotGroups: ReadonlyArray<readonly number[] | undefined> = [
772
+ run.panelOutputs?.map((output) => output.index),
773
+ run.panelFailures?.map((failure) => failure.index),
774
+ run.panelStoppedIndices,
775
+ run.recovery?.failedPanelIndices,
776
+ ];
777
+ for (const slots of slotGroups) {
778
+ for (const slot of slots ?? []) {
779
+ if (!isPanelSlotIndex(slot) || slot >= panelLength) {
780
+ return `Persisted panel slot ${String(slot)} is outside the configured panel.`;
781
+ }
782
+ }
783
+ }
784
+ return undefined;
785
+ }
786
+
787
+ function resolvePersistedQuorum(
788
+ policy: NonNullable<FusionRun["minimumSuccessfulPanelists"]>,
789
+ panelLength: number,
790
+ ): number {
791
+ if (policy === "all") return panelLength;
792
+ if (typeof policy === "number") {
793
+ return panelLength > 1 ? Math.max(2, Math.min(policy, panelLength)) : 1;
794
+ }
795
+ return Math.ceil(panelLength / 2);
796
+ }
797
+
587
798
  function isPanelOutputArray(
588
799
  value: unknown,
589
800
  ): value is NonNullable<FusionRun["panelOutputs"]> {
@@ -595,7 +806,7 @@ function isPanelOutput(
595
806
  ): value is NonNullable<FusionRun["panelOutputs"]>[number] {
596
807
  return (
597
808
  isRecord(value) &&
598
- isFiniteNumber(value.index) &&
809
+ isPanelSlotIndex(value.index) &&
599
810
  isNonEmptyString(value.agent) &&
600
811
  typeof value.output === "string" &&
601
812
  (value.id === undefined || typeof value.id === "string") &&
@@ -622,7 +833,7 @@ function isPanelFailure(
622
833
  ): value is NonNullable<FusionRun["panelFailures"]>[number] {
623
834
  return (
624
835
  isRecord(value) &&
625
- isFiniteNumber(value.index) &&
836
+ isPanelSlotIndex(value.index) &&
626
837
  isNonEmptyString(value.agent) &&
627
838
  typeof value.summary === "string" &&
628
839
  (value.id === undefined || typeof value.id === "string") &&
@@ -638,6 +849,144 @@ function isPanelFailure(
638
849
  );
639
850
  }
640
851
 
852
+ function isProfileSnapshot(value: unknown): value is FusionProfileSnapshot {
853
+ if (!isRecord(value) || !Array.isArray(value.panel) || value.panel.length === 0) {
854
+ return false;
855
+ }
856
+ if (!value.panel.every(isSnapshotPanelMember) || !isSnapshotJudge(value.judge)) {
857
+ return false;
858
+ }
859
+ if (
860
+ !isFiniteNumber(value.minimumSuccessfulPanelists) ||
861
+ !Number.isInteger(value.minimumSuccessfulPanelists) ||
862
+ value.minimumSuccessfulPanelists < 1 ||
863
+ value.minimumSuccessfulPanelists > value.panel.length
864
+ ) {
865
+ return false;
866
+ }
867
+ if (value.context !== undefined && value.context !== "fresh" && value.context !== "fork") {
868
+ return false;
869
+ }
870
+ if (value.stopWhenPanelAgrees !== undefined && typeof value.stopWhenPanelAgrees !== "boolean") {
871
+ return false;
872
+ }
873
+ if (value.blindPanelLabels !== undefined && typeof value.blindPanelLabels !== "boolean") {
874
+ return false;
875
+ }
876
+ if (value.synthesis !== undefined && value.synthesis !== "select" && value.synthesis !== "merge") {
877
+ return false;
878
+ }
879
+ return value.judgeToolBudget === undefined || isSnapshotToolBudget(value.judgeToolBudget);
880
+ }
881
+
882
+ function isSnapshotPanelMember(value: unknown): boolean {
883
+ if (!isRecord(value) || !isNonEmptyString(value.id) || !isSnapshotAgent(value.agent)) {
884
+ return false;
885
+ }
886
+ return (
887
+ (value.label === undefined || isNonEmptyString(value.label)) &&
888
+ (value.model === undefined || isNonEmptyString(value.model)) &&
889
+ (value.thinking === undefined || isThinkingLevel(value.thinking)) &&
890
+ (value.role === undefined || typeof value.role === "string") &&
891
+ (value.question === undefined || isNonEmptyString(value.question))
892
+ );
893
+ }
894
+
895
+ function isSnapshotJudge(value: unknown): boolean {
896
+ return (
897
+ isRecord(value) &&
898
+ isSnapshotAgent(value.agent) &&
899
+ (value.model === undefined || isNonEmptyString(value.model)) &&
900
+ (value.thinking === undefined || isThinkingLevel(value.thinking))
901
+ );
902
+ }
903
+
904
+ function isSnapshotAgent(value: unknown): boolean {
905
+ return (
906
+ isNonEmptyString(value) &&
907
+ /^[^\s.]+(?:\.[^\s.]+)*$/.test(value.trim())
908
+ );
909
+ }
910
+
911
+ function isThinkingLevel(value: unknown): boolean {
912
+ return value === "off" || value === "minimal" || value === "low" ||
913
+ value === "medium" || value === "high" || value === "xhigh";
914
+ }
915
+
916
+ function isSnapshotToolBudget(value: unknown): boolean {
917
+ if (!isRecord(value) || (value.soft === undefined && value.hard === undefined)) {
918
+ return false;
919
+ }
920
+ if (value.soft !== undefined && (!isFiniteNumber(value.soft) || !Number.isInteger(value.soft) || value.soft < 1)) {
921
+ return false;
922
+ }
923
+ if (value.hard !== undefined && (!isFiniteNumber(value.hard) || !Number.isInteger(value.hard) || value.hard < 1)) {
924
+ return false;
925
+ }
926
+ if (typeof value.soft === "number" && typeof value.hard === "number" && value.soft > value.hard) {
927
+ return false;
928
+ }
929
+ return value.block === undefined || value.block === "*" ||
930
+ (Array.isArray(value.block) && value.block.length > 0 && value.block.every(isNonEmptyString));
931
+ }
932
+
933
+ function cloneProfileSnapshot(snapshot: FusionProfileSnapshot): FusionProfileSnapshot {
934
+ return {
935
+ panel: snapshot.panel.map((member) => ({ ...member })),
936
+ judge: { ...snapshot.judge },
937
+ minimumSuccessfulPanelists: snapshot.minimumSuccessfulPanelists,
938
+ ...(snapshot.context !== undefined ? { context: snapshot.context } : {}),
939
+ ...(snapshot.stopWhenPanelAgrees !== undefined
940
+ ? { stopWhenPanelAgrees: snapshot.stopWhenPanelAgrees }
941
+ : {}),
942
+ ...(snapshot.blindPanelLabels !== undefined
943
+ ? { blindPanelLabels: snapshot.blindPanelLabels }
944
+ : {}),
945
+ ...(snapshot.judgeToolBudget !== undefined
946
+ ? {
947
+ judgeToolBudget: {
948
+ ...snapshot.judgeToolBudget,
949
+ ...(Array.isArray(snapshot.judgeToolBudget.block)
950
+ ? { block: [...snapshot.judgeToolBudget.block] }
951
+ : {}),
952
+ },
953
+ }
954
+ : {}),
955
+ ...(snapshot.synthesis !== undefined ? { synthesis: snapshot.synthesis } : {}),
956
+ };
957
+ }
958
+
959
+ function isRecoveryState(value: unknown): value is FusionRecoveryState {
960
+ return (
961
+ isRecord(value) &&
962
+ value.retryDeferred === true &&
963
+ Array.isArray(value.failedPanelIndices) &&
964
+ value.failedPanelIndices.every(isPanelSlotIndex)
965
+ );
966
+ }
967
+
968
+ function isPanelSlotIndex(value: unknown): value is number {
969
+ return isFiniteNumber(value) && Number.isInteger(value) && value >= 0;
970
+ }
971
+
972
+ function cloneRecovery(recovery: FusionRecoveryState): FusionRecoveryState {
973
+ return { ...recovery, failedPanelIndices: [...recovery.failedPanelIndices] };
974
+ }
975
+
976
+ function isSpawnIntent(value: unknown): value is FusionRun["spawnIntent"] {
977
+ return (
978
+ isRecord(value) &&
979
+ (value.stage === "panel" || value.stage === "judge") &&
980
+ isFiniteNumber(value.requestedAt)
981
+ );
982
+ }
983
+
984
+ function cloneSpawnIntent(
985
+ intent: NonNullable<FusionRun["spawnIntent"]>,
986
+ ): NonNullable<FusionRun["spawnIntent"]> {
987
+ return { ...intent };
988
+ }
989
+
641
990
  function clonePanelOutputs(
642
991
  outputs: NonNullable<FusionRun["panelOutputs"]>,
643
992
  ): NonNullable<FusionRun["panelOutputs"]> {
package/src/status.ts CHANGED
@@ -52,7 +52,7 @@ export function formatFusionStatusText(
52
52
  run.phase === "judge" ? run.judgeRunId : (run.chainRunId ?? run.panelRunId);
53
53
  const phase = phaseLabel ?? run.phase;
54
54
  if (progress) {
55
- return `fusion: panel · ${formatProgressCounts(progress)} · ${run.profileName}`;
55
+ return `fusion: ${phase} · ${formatProgressCounts(progress)} · ${run.profileName}`;
56
56
  }
57
57
  if (activeRunId) {
58
58
  return `fusion: ${phase} · ${run.profileName} · ${activeRunId}`;
package/src/types.ts CHANGED
@@ -45,16 +45,41 @@ export interface JudgeConfig {
45
45
  thinking?: ThinkingLevel;
46
46
  }
47
47
 
48
+ export type MinimumSuccessfulPanelists = "majority" | "all" | number;
49
+
50
+ /** Per-run deadline overrides accepted by CLI, tool, and RPC starts. */
51
+ export interface FusionTimeoutOverrides {
52
+ panelistTimeoutMs?: number;
53
+ panelTimeoutMs?: number;
54
+ panelGraceMs?: number;
55
+ judgeTimeoutMs?: number;
56
+ }
57
+
58
+ export interface EffectiveFusionTimeouts {
59
+ panelistTimeoutMs: number;
60
+ panelTimeoutMs: number;
61
+ panelGraceMs: number;
62
+ judgeTimeoutMs: number;
63
+ /** True when the legacy shared timeout supplied one or more deadline values. */
64
+ usesLegacyTimeout: boolean;
65
+ }
66
+
48
67
  export interface FusionProfile {
49
68
  panel: PanelMemberConfig[];
50
69
  judge: JudgeConfig;
51
70
  concurrency?: number;
52
71
  /** Legacy shared wall-clock timeout used when a stage timeout is absent. */
53
72
  timeoutMs?: number;
73
+ /** Per-child deadline. It is capped below the enclosing panel deadline. */
74
+ panelistTimeoutMs?: number;
54
75
  /** Wall-clock timeout for the complete panel workflow. */
55
76
  panelTimeoutMs?: number;
77
+ /** Time reserved between child and enclosing panel deadlines. */
78
+ panelGraceMs?: number;
56
79
  /** Wall-clock timeout for the synthesis workflow. */
57
80
  judgeTimeoutMs?: number;
81
+ /** Successful panel outputs required for synthesis. Defaults to a majority. */
82
+ minimumSuccessfulPanelists?: MinimumSuccessfulPanelists;
58
83
  context?: FusionContextMode;
59
84
  stopWhenPanelAgrees?: boolean;
60
85
  /**
@@ -172,6 +197,7 @@ export interface ParsedFusionArgs {
172
197
  outputContract?: CallerOutputContract;
173
198
  /** Inline panel entries from `--panel`: `<model>` or `<agent>:<model>`. */
174
199
  panel?: string[];
200
+ timeoutOverrides?: FusionTimeoutOverrides;
175
201
  }
176
202
 
177
203
  export interface PanelOutput {
@@ -210,6 +236,43 @@ export interface FailedPanelSummary {
210
236
  export type FusionPhase =
211
237
  "panel" | "chain" | "judge" | "done" | "failed" | "cancelled";
212
238
 
239
+ export type CompletionQuality = "complete" | "partial";
240
+
241
+ /**
242
+ * The start-time settings required after a process restart. This intentionally
243
+ * excludes panel execution-only values (concurrency, panel tool budget, and
244
+ * raw timeout fields): the panel is already running, while resolved deadlines
245
+ * are persisted separately in `effectiveTimeouts`.
246
+ */
247
+ export interface FusionProfileSnapshot {
248
+ panel: PanelMemberConfig[];
249
+ judge: JudgeConfig;
250
+ minimumSuccessfulPanelists: number;
251
+ context?: FusionContextMode;
252
+ stopWhenPanelAgrees?: boolean;
253
+ blindPanelLabels?: boolean;
254
+ judgeToolBudget?: ToolBudget;
255
+ synthesis?: FusionSynthesisMode;
256
+ }
257
+
258
+ /** Persisted recovery metadata for a terminal run. Failed-only retry is not
259
+ * yet exposed, so this records exactly which slots a future explicit retry may
260
+ * safely target without replaying verified completed work. */
261
+ export interface FusionRecoveryState {
262
+ retryDeferred: true;
263
+ failedPanelIndices: number[];
264
+ }
265
+
266
+ /**
267
+ * Durable record written before a public RPC spawn. The public API cannot
268
+ * query by this correlation token, so a restored intent without its returned
269
+ * run ID is deliberately failed instead of risking a duplicate remote run.
270
+ */
271
+ export interface FusionSpawnIntent {
272
+ stage: "panel" | "judge";
273
+ requestedAt: number;
274
+ }
275
+
213
276
  export interface FusionRun {
214
277
  id: string;
215
278
  prompt: string;
@@ -224,6 +287,16 @@ export interface FusionRun {
224
287
  baseProfileName?: string;
225
288
  operationId?: string;
226
289
  outputContract?: CallerOutputContract;
290
+ /** Persisted start policy so recovery is not changed by later config edits. */
291
+ minimumSuccessfulPanelists?: MinimumSuccessfulPanelists;
292
+ /**
293
+ * Additive start-time profile snapshot. Old sessions omit it and retain the
294
+ * legacy config lookup fallback during restore.
295
+ */
296
+ profileSnapshot?: FusionProfileSnapshot;
297
+ timeoutOverrides?: FusionTimeoutOverrides;
298
+ effectiveTimeouts?: EffectiveFusionTimeouts;
299
+ completionQuality?: CompletionQuality;
227
300
  phase: FusionPhase;
228
301
  createdAt: number;
229
302
  updatedAt: number;
@@ -238,6 +311,8 @@ export interface FusionRun {
238
311
  judgeObservation?: RunObservation;
239
312
  panelOutputs?: PanelOutput[];
240
313
  panelFailures?: FailedPanelSummary[];
314
+ recovery?: FusionRecoveryState;
315
+ spawnIntent?: FusionSpawnIntent;
241
316
  report?: string;
242
317
  error?: string;
243
318
  }