@alexeiled/pi-fusion 0.6.2 → 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.
@@ -1,4 +1,8 @@
1
1
  import { applyClaudeAliasShorthand } from "./claude-aliases.js";
2
+ import {
3
+ detectCallerOutputContract,
4
+ validateCallerOutput,
5
+ } from "./caller-contract.js";
2
6
  import {
3
7
  buildInlinePanelProfile,
4
8
  loadFusionConfig,
@@ -7,7 +11,10 @@ import {
7
11
  } from "./config.js";
8
12
  import { FusionArgsError } from "./errors.js";
9
13
  import { parseFusionArgs } from "./fusion-args.js";
10
- import { decidePanelCompletion } from "./panel-completion.js";
14
+ import {
15
+ decidePanelCompletion,
16
+ resolveMinimumSuccessfulPanelists,
17
+ } from "./panel-completion.js";
11
18
  import {
12
19
  renderCancelledReport,
13
20
  renderFailureReport,
@@ -17,8 +24,20 @@ import {
17
24
  extractPanelResults,
18
25
  type ExtractPanelResultsSuccess,
19
26
  } from "./result-extract.js";
20
- import { appendThinkingSuffix, buildPanelSpawnParams } from "./run-builder.js";
21
- import { FusionRunStore, FusionRunStoreError } from "./run-store.js";
27
+ import {
28
+ reconcileIndexedLifecycleResult,
29
+ reconcilePanelResults,
30
+ } from "./lifecycle-reconcile.js";
31
+ import {
32
+ appendThinkingSuffix,
33
+ buildPanelSpawnParams,
34
+ resolveEffectiveTimeouts,
35
+ } from "./run-builder.js";
36
+ import {
37
+ FusionRunStore,
38
+ FusionRunStoreError,
39
+ validateFusionRunPanelSlots,
40
+ } from "./run-store.js";
22
41
  import {
23
42
  clearFusionUi,
24
43
  extractFusionProgressCounts,
@@ -37,6 +56,7 @@ import {
37
56
  resolveSynthesisMode,
38
57
  type FailedPanelSummary,
39
58
  type FusionProfile,
59
+ type FusionProfileSnapshot,
40
60
  type FusionRun,
41
61
  type PanelOutput,
42
62
  type ParsedFusionArgs,
@@ -105,6 +125,8 @@ export type FusionCommandResult =
105
125
  interface RunLifecycleSnapshot {
106
126
  statusPayload?: unknown;
107
127
  resultPayload?: unknown;
128
+ /** Compact event results need stable slots; status/artifacts are ordered snapshots. */
129
+ resultSource?: "artifact" | "event" | "status";
108
130
  resultIsTerminal: boolean;
109
131
  resultArtifactPending?: boolean;
110
132
  }
@@ -138,6 +160,11 @@ export class FusionOrchestrator {
138
160
  this.context = ctx;
139
161
 
140
162
  const args = typeof input === "string" ? parseFusionArgs(input) : input;
163
+ const inputError = validateStartArgs(args);
164
+ if (inputError) {
165
+ this.notify(ctx, inputError, "error");
166
+ return { status: "failed", error: inputError };
167
+ }
141
168
  const existing = this.runStore.getActiveRun();
142
169
  if (existing) {
143
170
  const message = `Fusion run ${existing.id} is already active.`;
@@ -188,6 +215,9 @@ export class FusionOrchestrator {
188
215
  return { status: "failed", error: message };
189
216
  }
190
217
 
218
+ const outputContract =
219
+ args.outputContract ?? detectCallerOutputContract(args.prompt);
220
+ const profileSnapshot = snapshotProfile(resolved.profile);
191
221
  let run: FusionRun;
192
222
  try {
193
223
  run = this.runStore.startRun({
@@ -199,10 +229,25 @@ export class FusionOrchestrator {
199
229
  ...(args.operationId !== undefined
200
230
  ? { operationId: args.operationId }
201
231
  : {}),
232
+ ...(outputContract ? { outputContract } : {}),
233
+ // profileSnapshot is the sole quorum record for new runs. The
234
+ // run-level field remains readable only for legacy snapshots.
235
+ profileSnapshot,
236
+ ...(args.timeoutOverrides
237
+ ? { timeoutOverrides: args.timeoutOverrides }
238
+ : {}),
239
+ effectiveTimeouts: resolveEffectiveTimeouts(
240
+ resolved.profile,
241
+ args.timeoutOverrides,
242
+ ),
202
243
  phase: "panel",
203
244
  });
204
245
  } catch (error: unknown) {
205
- if (!(error instanceof FusionRunStoreError)) throw error;
246
+ if (!(error instanceof FusionRunStoreError)) {
247
+ const message = errorMessage(error);
248
+ this.notify(ctx, message, "error");
249
+ return { status: "failed", error: message };
250
+ }
206
251
  const active = this.runStore.getActiveRun();
207
252
  if (active) {
208
253
  this.notify(
@@ -214,12 +259,25 @@ export class FusionOrchestrator {
214
259
  }
215
260
  return { status: "failed", error: errorMessage(error) };
216
261
  }
217
- this.activeProfile = resolved.profile;
262
+ // Keep runtime behavior aligned with the exact durable profile that a
263
+ // restart will use, rather than retaining a mutable config object.
264
+ this.activeProfile = profileFromSnapshot(profileSnapshot);
218
265
  publishFusionStatus(ctx, run);
219
266
 
220
267
  try {
268
+ // Persist before the side effect. Public pi-subagents RPC has no
269
+ // correlation-key lookup, so restore treats this intent without its ID
270
+ // as unsafe to replay rather than creating an orphaned duplicate.
271
+ this.runStore.updateRun(run.id, {
272
+ spawnIntent: { stage: "panel", requestedAt: Date.now() },
273
+ });
221
274
  const spawnResult = await this.rpc.spawn(
222
- buildPanelSpawnParams(resolved.profile, args.prompt),
275
+ buildPanelSpawnParams(
276
+ resolved.profile,
277
+ args.prompt,
278
+ outputContract,
279
+ args.timeoutOverrides,
280
+ ),
223
281
  );
224
282
  const spawnError = extractSubagentFailure(spawnResult);
225
283
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -240,10 +298,19 @@ export class FusionOrchestrator {
240
298
  ? { status: "cancelled", run: cancelled, report: cancelled.report }
241
299
  : { status: "ignored" };
242
300
  }
243
- const updated = this.runStore.updateRun(run.id, {
244
- panelRunId,
245
- ...(panelAsyncDir ? { panelAsyncDir } : {}),
246
- });
301
+ let updated: FusionRun;
302
+ try {
303
+ updated = this.runStore.updateRun(run.id, {
304
+ spawnIntent: null,
305
+ panelRunId,
306
+ ...(panelAsyncDir ? { panelAsyncDir } : {}),
307
+ });
308
+ } catch (persistenceError: unknown) {
309
+ // The remote run is now known but its ID is not durable. It cannot be
310
+ // safely recovered through the public RPC, so stop it before failing.
311
+ await this.stopOrphanedRun(panelRunId);
312
+ throw persistenceError;
313
+ }
247
314
  publishFusionStatus(ctx, updated);
248
315
  this.ensureReconcileLoop();
249
316
  this.notify(
@@ -330,7 +397,7 @@ export class FusionOrchestrator {
330
397
  this.context,
331
398
  active,
332
399
  progress,
333
- deriveFusionStatusPhase(active, statusPayload),
400
+ deriveFusionStatusPhase(active, statusPayload, this.activeProfile),
334
401
  );
335
402
  }
336
403
  return statusPayload;
@@ -408,34 +475,72 @@ export class FusionOrchestrator {
408
475
  const summary = this.runStore.restoreFromSession(ctx);
409
476
  this.clearActiveRuntime();
410
477
 
478
+ const restoreError = this.runStore.getRestoreError();
479
+ if (restoreError) {
480
+ this.configWarning = restoreError;
481
+ this.notify(ctx, restoreError, "warning");
482
+ clearFusionUi(ctx);
483
+ return summary;
484
+ }
485
+
411
486
  const active = this.runStore.getActiveRun();
487
+ if (active && hasUnresolvedSpawnIntent(active)) {
488
+ const message = `Fusion recovery stopped: ${active.spawnIntent!.stage} spawn may have reached pi-subagents, but its run ID was not persisted. It will not be replayed because public RPC cannot safely adopt it.`;
489
+ this.failActiveRun(message);
490
+ this.notify(ctx, message, "warning");
491
+ return this.runStore.getLastRunSummary();
492
+ }
412
493
  if (!active) {
413
494
  this.stopReconcileLoop();
414
495
  clearFusionUi(ctx);
415
496
  return summary;
416
497
  }
417
498
 
418
- try {
419
- const config = await this.loadConfig(ctx);
420
- // An inline run's profileName is a display name that no config defines,
421
- // so rebuild it from the base profile plus the persisted entries. Looking
422
- // the display name up throws, which used to leave activeProfile undefined
423
- // and fail the run the moment its panel completed.
424
- const base = this.resolveProfile(
425
- config,
426
- active.inlinePanel?.length
427
- ? active.baseProfileName
428
- : active.profileName,
429
- ).profile;
430
- this.activeProfile = active.inlinePanel?.length
431
- ? buildInlinePanelProfile(base, active.inlinePanel)
432
- : base;
499
+ const lifecycleError = validateRestoredRunLifecycle(active);
500
+ if (lifecycleError) {
501
+ this.failActiveRun(lifecycleError);
502
+ this.notify(ctx, lifecycleError, "warning");
503
+ return this.runStore.getLastRunSummary();
504
+ }
505
+
506
+ if (active.profileSnapshot) {
507
+ // The snapshot was schema-validated while deserializing the run. It is
508
+ // the source of truth for labels, quorum, synthesis, and judge spawning;
509
+ // config edits made while a run is active must not rewrite that run.
510
+ this.activeProfile = profileFromSnapshot(active.profileSnapshot);
433
511
  this.configWarning = undefined;
434
- } catch (error: unknown) {
435
- const message = `Could not restore fusion profile "${active.profileName}": ${errorMessage(error)}`;
436
- this.configWarning = message;
437
- this.notify(ctx, message, "warning");
512
+ } else {
513
+ try {
514
+ const config = await this.loadConfig(ctx);
515
+ // Backward compatibility for sessions written before profile snapshots.
516
+ // An inline run's display name has no config entry, so rebuild it from
517
+ // its base profile plus persisted inline entries.
518
+ const base = this.resolveProfile(
519
+ config,
520
+ active.inlinePanel?.length
521
+ ? active.baseProfileName
522
+ : active.profileName,
523
+ ).profile;
524
+ this.activeProfile = active.inlinePanel?.length
525
+ ? buildInlinePanelProfile(base, active.inlinePanel)
526
+ : base;
527
+ this.configWarning = undefined;
528
+ } catch (error: unknown) {
529
+ const message = `Could not restore legacy fusion profile "${active.profileName}": ${errorMessage(error)}`;
530
+ this.configWarning = message;
531
+ this.notify(ctx, message, "warning");
532
+ }
533
+ }
534
+ const panelSlotError = validateFusionRunPanelSlots(
535
+ active,
536
+ this.activeProfile?.panel.length ?? 0,
537
+ );
538
+ if (panelSlotError) {
539
+ this.failActiveRun(panelSlotError);
540
+ this.notify(ctx, panelSlotError, "warning");
541
+ return this.runStore.getLastRunSummary();
438
542
  }
543
+
439
544
  publishFusionStatus(ctx, active);
440
545
  this.ensureReconcileLoop();
441
546
  await this.reconcileActiveRun();
@@ -559,6 +664,7 @@ export class FusionOrchestrator {
559
664
  eventPayload: payload,
560
665
  });
561
666
  if (snapshot.resultArtifactPending) return { status: "ignored" };
667
+ this.persistVerifiedPanelResults(active, profile, snapshot.statusPayload);
562
668
  const terminalPayload =
563
669
  snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
564
670
  if (
@@ -578,6 +684,9 @@ export class FusionOrchestrator {
578
684
  const extracted = extractPanelResults(lifecyclePayload, {
579
685
  panel: profile.panel,
580
686
  limit: profile.panel.length,
687
+ ...(snapshot.resultSource === "event"
688
+ ? { requireStableSlotIdentity: true }
689
+ : {}),
581
690
  });
582
691
  if (!extracted.ok) {
583
692
  return this.failActiveRun(
@@ -585,19 +694,63 @@ export class FusionOrchestrator {
585
694
  );
586
695
  }
587
696
 
588
- const observedPanels = mergePanelObservations(
697
+ const observedPanels = reconcilePanelResults(
589
698
  extracted,
590
699
  snapshot.statusPayload,
591
700
  profile,
701
+ lifecyclePayload,
702
+ {
703
+ allowedTrailingResults: 1,
704
+ ...(isWorkflowDeadline(lifecyclePayload) ||
705
+ isWorkflowDeadline(snapshot.statusPayload)
706
+ ? { terminalizeRunning: true }
707
+ : {}),
708
+ },
592
709
  );
710
+ if (!observedPanels.ok) {
711
+ return this.failActiveRun(
712
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
713
+ );
714
+ }
593
715
  const updated = this.storePanelResults(
594
716
  active.id,
595
717
  observedPanels.outputs,
596
718
  observedPanels.failures,
597
719
  );
598
720
 
599
- if (hasJudgeResult(snapshot.resultPayload, profile.panel.length)) {
600
- const output = extractJudgeOutput(snapshot.resultPayload, {
721
+ // A legacy workflow may contain an embedded judge result even when the
722
+ // current persisted quorum policy would not permit synthesis. Apply the
723
+ // same completion decision as modern panel-only runs before accepting it.
724
+ const completion = decidePanelCompletion({
725
+ run: updated,
726
+ profile,
727
+ panelOutputs: observedPanels.outputs,
728
+ panelFailures: observedPanels.failures,
729
+ fallbackJudge: true,
730
+ });
731
+ if (completion.kind === "fail") {
732
+ return this.failActiveRun(completion.error, completion.report);
733
+ }
734
+ if (completion.kind === "complete") {
735
+ this.runStore.updateRun(updated.id, {
736
+ completionQuality: completionQuality(profile, observedPanels.outputs, observedPanels.failures),
737
+ });
738
+ return this.completeActiveRun(completion.report);
739
+ }
740
+
741
+ const judgePayload =
742
+ hasJudgeResult(snapshot.resultPayload, profile.panel.length)
743
+ ? snapshot.resultPayload
744
+ : snapshot.statusPayload;
745
+ if (hasJudgeResult(judgePayload, profile.panel.length)) {
746
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
747
+ snapshot.resultPayload,
748
+ snapshot.statusPayload,
749
+ profile.panel.length,
750
+ "judge",
751
+ );
752
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
753
+ const output = extractJudgeOutput(judgePayload, {
601
754
  resultIndex: profile.panel.length,
602
755
  });
603
756
  if (!output.ok) return this.failActiveRun(output.error);
@@ -608,13 +761,19 @@ export class FusionOrchestrator {
608
761
  snapshot.statusPayload,
609
762
  ),
610
763
  extractRunObservation(
611
- findResult(snapshot.resultPayload, profile.panel.length) ??
612
- snapshot.resultPayload,
764
+ findResult(judgePayload, profile.panel.length) ?? judgePayload,
613
765
  ),
614
766
  );
615
767
  const observed = this.runStore.updateRun(updated.id, {
768
+ completionQuality: completionQuality(profile, observedPanels.outputs, observedPanels.failures),
616
769
  judgeObservation,
617
770
  });
771
+ const callerContract =
772
+ observed.outputContract ?? detectCallerOutputContract(observed.prompt);
773
+ if (callerContract) {
774
+ const validation = validateCallerOutput(callerContract, output.output);
775
+ if (!validation.ok) return this.failActiveRun(validation.error);
776
+ }
618
777
  const report = renderJudgeReport({
619
778
  run: observed,
620
779
  judgeOutput: output.output,
@@ -660,17 +819,23 @@ export class FusionOrchestrator {
660
819
  });
661
820
  if (snapshot.resultArtifactPending) return { status: "ignored" };
662
821
 
822
+ const partial = this.persistVerifiedPanelResults(
823
+ active,
824
+ profile,
825
+ snapshot.statusPayload,
826
+ );
663
827
  const panelIsTerminal =
664
828
  snapshot.resultIsTerminal ||
665
829
  isTerminalSubagentState(extractSubagentState(snapshot.statusPayload));
666
830
  if (!active.panelStopReason && !panelIsTerminal) {
667
- const partial = extractPanelResults(snapshot.statusPayload, {
668
- panel: profile.panel,
669
- completedOnly: true,
670
- });
671
831
  if (
672
- partial.ok &&
673
- shouldStopWhenPanelAgrees(profile, partial.outputs, partial.failures)
832
+ partial &&
833
+ shouldStopWhenPanelAgrees(
834
+ profile,
835
+ partial.outputs,
836
+ partial.failures,
837
+ active.minimumSuccessfulPanelists,
838
+ )
674
839
  ) {
675
840
  return this.stopPanelAfterAgreement(
676
841
  active,
@@ -699,6 +864,11 @@ export class FusionOrchestrator {
699
864
  const workflowStoppedIndices = extractWorkflowStoppedPanelIndices(
700
865
  lifecyclePayload,
701
866
  );
867
+ if (workflowStoppedIndices.some((index) => index >= profile.panel.length)) {
868
+ return this.failActiveRun(
869
+ "Terminal subagents data referenced a panel slot outside the configured panel.",
870
+ );
871
+ }
702
872
  const stoppedPanelIndices =
703
873
  active.panelStoppedIndices ??
704
874
  (workflowStoppedIndices.length > 0 ? workflowStoppedIndices : undefined);
@@ -706,6 +876,9 @@ export class FusionOrchestrator {
706
876
  panel: profile.panel,
707
877
  limit: profile.panel.length,
708
878
  ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}),
879
+ ...(snapshot.resultSource === "event"
880
+ ? { requireStableSlotIdentity: true }
881
+ : {}),
709
882
  });
710
883
  if (!extracted.ok) {
711
884
  return this.failActiveRun(
@@ -713,15 +886,36 @@ export class FusionOrchestrator {
713
886
  );
714
887
  }
715
888
 
716
- const observedPanels = mergePanelObservations(
889
+ const observedPanels = reconcilePanelResults(
717
890
  extracted,
718
891
  snapshot.statusPayload,
719
892
  profile,
893
+ lifecyclePayload,
894
+ {
895
+ ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}),
896
+ ...(isWorkflowDeadline(lifecyclePayload) ||
897
+ isWorkflowDeadline(snapshot.statusPayload)
898
+ ? { terminalizeRunning: true }
899
+ : {}),
900
+ },
901
+ );
902
+ if (!observedPanels.ok) {
903
+ return this.failActiveRun(
904
+ `${observedPanels.error.message} (${observedPanels.error.path})`,
905
+ );
906
+ }
907
+ const reconciledFailures = withWorkflowDeadlineFailures(
908
+ observedPanels.failures,
909
+ isWorkflowDeadline(lifecyclePayload) ||
910
+ isWorkflowDeadline(snapshot.statusPayload)
911
+ ? extractSubagentFailure(snapshot.statusPayload) ??
912
+ extractSubagentFailure(lifecyclePayload)
913
+ : undefined,
720
914
  );
721
915
  const stored = this.storePanelResults(
722
916
  active.id,
723
917
  observedPanels.outputs,
724
- observedPanels.failures,
918
+ reconciledFailures,
725
919
  );
726
920
  const updated =
727
921
  workflowStoppedIndices.length > 0 && !active.panelStopReason
@@ -735,7 +929,7 @@ export class FusionOrchestrator {
735
929
  updated,
736
930
  profile,
737
931
  observedPanels.outputs,
738
- observedPanels.failures,
932
+ reconciledFailures,
739
933
  { fallbackJudge: false },
740
934
  );
741
935
  }
@@ -813,10 +1007,16 @@ export class FusionOrchestrator {
813
1007
  return this.failActiveRun(decision.error, decision.report);
814
1008
  }
815
1009
  if (decision.kind === "complete") {
1010
+ this.runStore.updateRun(run.id, {
1011
+ completionQuality: completionQuality(profile, panelOutputs, panelFailures),
1012
+ });
816
1013
  return this.completeActiveRun(decision.report);
817
1014
  }
818
1015
 
819
1016
  try {
1017
+ this.runStore.updateRun(run.id, {
1018
+ spawnIntent: { stage: "judge", requestedAt: Date.now() },
1019
+ });
820
1020
  const spawnResult = await this.rpc.spawn(decision.params);
821
1021
  const spawnError = extractSubagentFailure(spawnResult);
822
1022
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -829,13 +1029,23 @@ export class FusionOrchestrator {
829
1029
  await this.stopOrphanedRun(judgeRunId, "judge");
830
1030
  return { status: "ignored" };
831
1031
  }
832
- const nextRun = this.runStore.updateRun(run.id, {
833
- phase: "judge",
834
- judgeRunId,
835
- ...(judgeAsyncDir ? { judgeAsyncDir } : {}),
836
- panelOutputs: [...panelOutputs],
837
- panelFailures: [...panelFailures],
838
- });
1032
+ let nextRun: FusionRun;
1033
+ try {
1034
+ nextRun = this.runStore.updateRun(run.id, {
1035
+ spawnIntent: null,
1036
+ phase: "judge",
1037
+ completionQuality: completionQuality(profile, panelOutputs, panelFailures),
1038
+ judgeRunId,
1039
+ ...(judgeAsyncDir ? { judgeAsyncDir } : {}),
1040
+ panelOutputs: [...panelOutputs],
1041
+ panelFailures: [...panelFailures],
1042
+ });
1043
+ } catch (persistenceError: unknown) {
1044
+ // As for the panel, never leave a remotely started synthesis run
1045
+ // alive when recording its public ID failed.
1046
+ await this.stopOrphanedRun(judgeRunId, "judge");
1047
+ throw persistenceError;
1048
+ }
839
1049
  publishFusionStatus(this.context, nextRun);
840
1050
  this.notify(
841
1051
  this.context,
@@ -875,8 +1085,25 @@ export class FusionOrchestrator {
875
1085
  return this.failActiveRun(lifecycleError);
876
1086
  }
877
1087
 
1088
+ const lifecycleConflict = reconcileIndexedLifecycleResult(
1089
+ snapshot.resultPayload,
1090
+ snapshot.statusPayload,
1091
+ 0,
1092
+ "judge",
1093
+ );
1094
+ if (lifecycleConflict) return this.failActiveRun(lifecycleConflict);
1095
+
878
1096
  const output = extractJudgeOutput(lifecyclePayload);
879
- if (!output.ok) return this.failActiveRun(output.error);
1097
+ if (!output.ok) {
1098
+ const stepError = extractSubagentFailure(
1099
+ findStepsArray(snapshot.statusPayload)[0],
1100
+ );
1101
+ const errors = [lifecycleError, stepError, output.error].filter(
1102
+ (error, index, all): error is string =>
1103
+ Boolean(error) && all.indexOf(error) === index,
1104
+ );
1105
+ return this.failActiveRun(errors.join(" "));
1106
+ }
880
1107
 
881
1108
  // The panel and chain handlers already treat a missing profile as fatal.
882
1109
  // Without the same guard here the run "succeeds" with a degraded report:
@@ -888,6 +1115,13 @@ export class FusionOrchestrator {
888
1115
  );
889
1116
  }
890
1117
 
1118
+ const callerContract =
1119
+ active.outputContract ?? detectCallerOutputContract(active.prompt);
1120
+ if (callerContract) {
1121
+ const validation = validateCallerOutput(callerContract, output.output);
1122
+ if (!validation.ok) return this.failActiveRun(validation.error);
1123
+ }
1124
+
891
1125
  const judgeModel = configuredJudgeModel(profile);
892
1126
  const judgeObservation = mergeRunObservations(
893
1127
  extractRunObservation(
@@ -940,7 +1174,7 @@ export class FusionOrchestrator {
940
1174
  this.context,
941
1175
  input.run,
942
1176
  progress,
943
- deriveFusionStatusPhase(input.run, statusPayload),
1177
+ deriveFusionStatusPhase(input.run, statusPayload, this.activeProfile),
944
1178
  );
945
1179
  }
946
1180
 
@@ -950,23 +1184,20 @@ export class FusionOrchestrator {
950
1184
  const eventIsTerminal =
951
1185
  isTerminalSubagentState(extractSubagentState(input.eventPayload)) ||
952
1186
  isTerminalFusionProgress(input.eventPayload);
953
- const eventHasResults = hasResultsArray(input.eventPayload);
954
- if (eventPayloadMatches && eventHasResults) {
955
- return {
956
- statusPayload,
957
- resultPayload: input.eventPayload,
958
- resultIsTerminal: eventIsTerminal || statusIsTerminal,
959
- };
960
- }
961
-
1187
+ const eventHasResults = hasExplicitResultsArray(input.eventPayload);
1188
+ // Completion events are compact and can truncate long inline outputs. A
1189
+ // result artifact is the complete terminal record, so select it before an
1190
+ // event and use it as the reconciliation snapshot as well. Otherwise a
1191
+ // partial status/event could discard verified slots or the judge end tag.
962
1192
  const artifactResult = readSubagentResultArtifact({
963
1193
  ...(input.runId ? { runId: input.runId } : {}),
964
1194
  ...(input.asyncDir ? { asyncDir: input.asyncDir } : {}),
965
1195
  });
966
- if (hasResultsArray(artifactResult)) {
1196
+ if (hasExplicitResultsArray(artifactResult)) {
967
1197
  return {
968
- statusPayload,
1198
+ statusPayload: artifactResult,
969
1199
  resultPayload: artifactResult,
1200
+ resultSource: "artifact",
970
1201
  resultIsTerminal: true,
971
1202
  };
972
1203
  }
@@ -982,10 +1213,20 @@ export class FusionOrchestrator {
982
1213
  };
983
1214
  }
984
1215
 
985
- if (hasResultsArray(statusPayload)) {
1216
+ if (eventPayloadMatches && eventHasResults) {
1217
+ return {
1218
+ statusPayload,
1219
+ resultPayload: input.eventPayload,
1220
+ resultSource: "event",
1221
+ resultIsTerminal: eventIsTerminal || statusIsTerminal,
1222
+ };
1223
+ }
1224
+
1225
+ if (hasExplicitResultsArray(statusPayload)) {
986
1226
  return {
987
1227
  statusPayload,
988
1228
  resultPayload: statusPayload,
1229
+ resultSource: "status",
989
1230
  resultIsTerminal: statusIsTerminal,
990
1231
  };
991
1232
  }
@@ -1009,14 +1250,64 @@ export class FusionOrchestrator {
1009
1250
  return { statusPayload, resultIsTerminal: false };
1010
1251
  }
1011
1252
 
1253
+ /** Persists every terminal child slot observed while the workflow continues. */
1254
+ private persistVerifiedPanelResults(
1255
+ run: FusionRun,
1256
+ profile: FusionProfile,
1257
+ statusPayload: unknown,
1258
+ ): ExtractPanelResultsSuccess | undefined {
1259
+ if (statusPayload === undefined) return undefined;
1260
+ const partial = extractPanelResults(statusPayload, {
1261
+ panel: profile.panel,
1262
+ completedOnly: true,
1263
+ limit: profile.panel.length,
1264
+ });
1265
+ if (!partial.ok) return undefined;
1266
+ if (partial.outputs.length > 0 || partial.failures.length > 0) {
1267
+ this.storePanelResults(run.id, partial.outputs, partial.failures);
1268
+ }
1269
+ return partial;
1270
+ }
1271
+
1012
1272
  private storePanelResults(
1013
1273
  runId: string,
1014
1274
  panelOutputs: readonly PanelOutput[],
1015
1275
  panelFailures: readonly FailedPanelSummary[],
1016
1276
  ): FusionRun {
1277
+ // Lifecycle status is append-only in intent but not guaranteed to repeat
1278
+ // older slots on every poll. Keep persisted verified slots until a newer
1279
+ // observation for that exact stable index supersedes them.
1280
+ const existing = this.runStore.getActiveRun();
1281
+ const slots = new Map<
1282
+ number,
1283
+ { output?: PanelOutput; failure?: FailedPanelSummary }
1284
+ >();
1285
+ for (const output of existing?.panelOutputs ?? []) {
1286
+ slots.set(output.index, { output });
1287
+ }
1288
+ for (const failure of existing?.panelFailures ?? []) {
1289
+ slots.set(failure.index, { failure });
1290
+ }
1291
+ for (const output of panelOutputs) slots.set(output.index, { output });
1292
+ for (const failure of panelFailures) slots.set(failure.index, { failure });
1293
+ const mergedOutputs = Array.from(slots.values())
1294
+ .flatMap((slot) => (slot.output ? [slot.output] : []))
1295
+ .sort((left, right) => left.index - right.index);
1296
+ const mergedFailures = Array.from(slots.values())
1297
+ .flatMap((slot) => (slot.failure ? [slot.failure] : []))
1298
+ .sort((left, right) => left.index - right.index);
1299
+ // Status polling repeats complete snapshots. Persist only a material slot
1300
+ // change so a long-running restore does not append identical session data.
1301
+ if (
1302
+ existing?.id === runId &&
1303
+ sameSnapshot(existing.panelOutputs, mergedOutputs) &&
1304
+ sameSnapshot(existing.panelFailures, mergedFailures)
1305
+ ) {
1306
+ return existing;
1307
+ }
1017
1308
  return this.runStore.updateRun(runId, {
1018
- panelOutputs: [...panelOutputs],
1019
- panelFailures: [...panelFailures],
1309
+ panelOutputs: mergedOutputs,
1310
+ panelFailures: mergedFailures,
1020
1311
  });
1021
1312
  }
1022
1313
 
@@ -1047,6 +1338,12 @@ export class FusionOrchestrator {
1047
1338
  ...(active.chainRunId ? { chainRunId: active.chainRunId } : {}),
1048
1339
  ...(active.panelRunId ? { panelRunId: active.panelRunId } : {}),
1049
1340
  ...(active.judgeRunId ? { judgeRunId: active.judgeRunId } : {}),
1341
+ recovery: {
1342
+ retryDeferred: true,
1343
+ failedPanelIndices: storedPanelFailures(active)
1344
+ .map((failure) => failure.index)
1345
+ .sort((left, right) => left - right),
1346
+ },
1050
1347
  report,
1051
1348
  error,
1052
1349
  });
@@ -1134,6 +1431,124 @@ export class FusionOrchestrator {
1134
1431
  }
1135
1432
  }
1136
1433
 
1434
+ function sameSnapshot<T>(
1435
+ left: readonly T[] | undefined,
1436
+ right: readonly T[],
1437
+ ): boolean {
1438
+ return JSON.stringify(left ?? []) === JSON.stringify(right);
1439
+ }
1440
+
1441
+ /** Captures only settings that can affect post-restart reconciliation/reporting. */
1442
+ function snapshotProfile(profile: FusionProfile): FusionProfileSnapshot {
1443
+ return {
1444
+ panel: profile.panel.map((member) => ({ ...member })),
1445
+ judge: { ...profile.judge },
1446
+ minimumSuccessfulPanelists: resolveMinimumSuccessfulPanelists(
1447
+ profile.minimumSuccessfulPanelists,
1448
+ profile.panel.length,
1449
+ ),
1450
+ ...(profile.context !== undefined ? { context: profile.context } : {}),
1451
+ ...(profile.stopWhenPanelAgrees !== undefined
1452
+ ? { stopWhenPanelAgrees: profile.stopWhenPanelAgrees }
1453
+ : {}),
1454
+ ...(profile.blindPanelLabels !== undefined
1455
+ ? { blindPanelLabels: profile.blindPanelLabels }
1456
+ : {}),
1457
+ ...(profile.judgeToolBudget !== undefined
1458
+ ? {
1459
+ judgeToolBudget: {
1460
+ ...profile.judgeToolBudget,
1461
+ ...(Array.isArray(profile.judgeToolBudget.block)
1462
+ ? { block: [...profile.judgeToolBudget.block] }
1463
+ : {}),
1464
+ },
1465
+ }
1466
+ : {}),
1467
+ ...(profile.synthesis !== undefined ? { synthesis: profile.synthesis } : {}),
1468
+ };
1469
+ }
1470
+
1471
+ function profileFromSnapshot(snapshot: FusionProfileSnapshot): FusionProfile {
1472
+ return {
1473
+ panel: snapshot.panel.map((member) => ({ ...member })),
1474
+ judge: { ...snapshot.judge },
1475
+ minimumSuccessfulPanelists: snapshot.minimumSuccessfulPanelists,
1476
+ ...(snapshot.context !== undefined ? { context: snapshot.context } : {}),
1477
+ ...(snapshot.stopWhenPanelAgrees !== undefined
1478
+ ? { stopWhenPanelAgrees: snapshot.stopWhenPanelAgrees }
1479
+ : {}),
1480
+ ...(snapshot.blindPanelLabels !== undefined
1481
+ ? { blindPanelLabels: snapshot.blindPanelLabels }
1482
+ : {}),
1483
+ ...(snapshot.judgeToolBudget !== undefined
1484
+ ? {
1485
+ judgeToolBudget: {
1486
+ ...snapshot.judgeToolBudget,
1487
+ ...(Array.isArray(snapshot.judgeToolBudget.block)
1488
+ ? { block: [...snapshot.judgeToolBudget.block] }
1489
+ : {}),
1490
+ },
1491
+ }
1492
+ : {}),
1493
+ ...(snapshot.synthesis !== undefined ? { synthesis: snapshot.synthesis } : {}),
1494
+ };
1495
+ }
1496
+
1497
+ function completionQuality(
1498
+ profile: FusionProfile,
1499
+ outputs: readonly PanelOutput[],
1500
+ failures: readonly FailedPanelSummary[],
1501
+ ): "complete" | "partial" {
1502
+ return outputs.length === profile.panel.length ||
1503
+ (profile.stopWhenPanelAgrees === true &&
1504
+ failures.length > 0 &&
1505
+ failures.every((failure) => failure.reason === "stopped-after-agreement"))
1506
+ ? "complete"
1507
+ : "partial";
1508
+ }
1509
+
1510
+ function validateStartArgs(args: ParsedFusionArgs): string | undefined {
1511
+ if (typeof args.prompt !== "string" || !args.prompt.trim()) {
1512
+ return "Fusion prompt must not be blank.";
1513
+ }
1514
+ if (args.profile !== undefined && !args.profile.trim()) {
1515
+ return "Fusion profile must not be blank.";
1516
+ }
1517
+ if (
1518
+ args.panel !== undefined &&
1519
+ (args.panel.length === 0 || args.panel.some((entry) => !entry.trim()))
1520
+ ) {
1521
+ return "Fusion panel must contain at least one non-blank entry.";
1522
+ }
1523
+ return undefined;
1524
+ }
1525
+
1526
+ function hasUnresolvedSpawnIntent(run: FusionRun): boolean {
1527
+ if (!run.spawnIntent) return false;
1528
+ return run.spawnIntent.stage === "panel"
1529
+ ? !run.panelRunId && !run.chainRunId
1530
+ : !run.judgeRunId;
1531
+ }
1532
+
1533
+ /**
1534
+ * A restored nonterminal phase without the remote ID cannot be reconciled.
1535
+ * Spawn intents are handled first so their more specific no-replay failure is
1536
+ * preserved; all other incomplete records are terminalized rather than left
1537
+ * active forever.
1538
+ */
1539
+ function validateRestoredRunLifecycle(run: FusionRun): string | undefined {
1540
+ if (run.phase === "judge" && !run.judgeRunId) {
1541
+ return "Fusion recovery stopped: judge phase has no persisted judge run ID.";
1542
+ }
1543
+ if (run.phase === "chain" && !run.chainRunId) {
1544
+ return "Fusion recovery stopped: chain phase has no persisted chain run ID.";
1545
+ }
1546
+ if (run.phase === "panel" && !run.panelRunId && !run.chainRunId) {
1547
+ return "Fusion recovery stopped: panel phase has no persisted panel run ID.";
1548
+ }
1549
+ return undefined;
1550
+ }
1551
+
1137
1552
  export function extractSubagentRunId(payload: unknown): string | undefined {
1138
1553
  if (!isRecord(payload)) return undefined;
1139
1554
  const direct = firstNonBlankString(
@@ -1238,14 +1653,17 @@ function formatFusionStatusReport(input: {
1238
1653
  );
1239
1654
  lines.push(`Profile: ${input.active.profileName}`);
1240
1655
  lines.push(`Phase: ${input.details?.phaseLabel ?? input.active.phase}`);
1656
+ appendEffectiveTimeouts(lines, input.active);
1241
1657
  if (input.active.chainRunId)
1242
1658
  lines.push(`Chain run: ${input.active.chainRunId}`);
1243
1659
  else if (input.active.panelRunId)
1244
1660
  lines.push(`Panel run: ${input.active.panelRunId}`);
1245
1661
  if (input.active.judgeRunId) {
1246
- lines.push(
1247
- `${input.active.chainRunId ? "Fallback judge run" : "Judge run"}: ${input.active.judgeRunId}`,
1248
- );
1662
+ const synthesisLabel =
1663
+ input.details?.fallbackJudge?.label ??
1664
+ input.details?.judge?.label ??
1665
+ (input.active.chainRunId ? "Fallback judge" : "Judge");
1666
+ lines.push(`${synthesisLabel} run: ${input.active.judgeRunId}`);
1249
1667
  }
1250
1668
  lines.push(
1251
1669
  `Progress: ${input.progress ? formatProgressCounts(input.progress) : "unknown"}`,
@@ -1257,6 +1675,7 @@ function formatFusionStatusReport(input: {
1257
1675
  lines.push(`Prompt: ${firstLine(input.last.prompt)}`);
1258
1676
  lines.push(`Profile: ${input.last.profileName}`);
1259
1677
  lines.push(`Phase: ${input.last.phase}`);
1678
+ appendEffectiveTimeouts(lines, input.last);
1260
1679
  if (input.last.chainRunId)
1261
1680
  lines.push(`Chain run: ${input.last.chainRunId}`);
1262
1681
  else if (input.last.panelRunId)
@@ -1278,6 +1697,20 @@ function formatFusionStatusReport(input: {
1278
1697
  return lines.join("\n");
1279
1698
  }
1280
1699
 
1700
+ function appendEffectiveTimeouts(
1701
+ lines: string[],
1702
+ run: Pick<FusionRun, "effectiveTimeouts">,
1703
+ ): void {
1704
+ const timeouts = run.effectiveTimeouts;
1705
+ if (!timeouts) return;
1706
+ lines.push(
1707
+ `Deadlines: panelist ${timeouts.panelistTimeoutMs}ms, panel ${timeouts.panelTimeoutMs}ms (+${timeouts.panelGraceMs}ms grace), judge ${timeouts.judgeTimeoutMs}ms`,
1708
+ );
1709
+ if (timeouts.usesLegacyTimeout) {
1710
+ lines.push("Warning: legacy timeoutMs supplied one or more effective deadlines.");
1711
+ }
1712
+ }
1713
+
1281
1714
  function appendStatusDetails(
1282
1715
  lines: string[],
1283
1716
  details: FusionStatusDetails | undefined,
@@ -1332,7 +1765,7 @@ function buildFusionStatusDetails(
1332
1765
  ): FusionStatusDetails {
1333
1766
  const details: FusionStatusDetails = {
1334
1767
  prompt: run.prompt,
1335
- phaseLabel: deriveFusionStatusPhase(run, payload),
1768
+ phaseLabel: deriveFusionStatusPhase(run, payload, profile),
1336
1769
  };
1337
1770
  if (!profile) return details;
1338
1771
 
@@ -1345,7 +1778,7 @@ function buildFusionStatusDetails(
1345
1778
  storedPanelFailures(run),
1346
1779
  );
1347
1780
  details.fallbackJudge = {
1348
- label: run.chainRunId ? "Fallback judge" : "Judge",
1781
+ label: synthesisStatusLabel(profile, Boolean(run.chainRunId)),
1349
1782
  ...(judgeModel ? { model: judgeModel } : {}),
1350
1783
  status: describeStandaloneRunStatus(payload),
1351
1784
  };
@@ -1374,7 +1807,7 @@ function buildFusionStatusDetails(
1374
1807
  const judgeActivity = describeStepActivity(steps[profile.panel.length]);
1375
1808
  const judgeMetrics = describeStepMetrics(steps[profile.panel.length]);
1376
1809
  details.judge = {
1377
- label: "Judge",
1810
+ label: synthesisStatusLabel(profile),
1378
1811
  ...(judgeModel ? { model: judgeModel } : {}),
1379
1812
  status: describeChainJudgeStatus(steps[profile.panel.length], panelists),
1380
1813
  ...([judgeActivity, judgeMetrics].filter(Boolean).length > 0
@@ -1387,9 +1820,10 @@ function buildFusionStatusDetails(
1387
1820
  function deriveFusionStatusPhase(
1388
1821
  run: Pick<FusionRun, "phase" | "chainRunId">,
1389
1822
  payload: unknown,
1823
+ profile?: Pick<FusionProfile, "panel" | "synthesis">,
1390
1824
  ): string {
1391
1825
  if (run.phase === "judge") {
1392
- return run.chainRunId ? "fallback judge" : "judge";
1826
+ return synthesisStatusLabel(profile, Boolean(run.chainRunId)).toLowerCase();
1393
1827
  }
1394
1828
  if (run.phase !== "chain") return run.phase;
1395
1829
 
@@ -1397,7 +1831,7 @@ function deriveFusionStatusPhase(
1397
1831
  if (steps.length === 0) return "chain";
1398
1832
  const judgeStatus = normalizeStatusLabel(steps.at(-1));
1399
1833
  if (judgeStatus === "running" || judgeStatus === "completed") {
1400
- return "judge";
1834
+ return synthesisStatusLabel(profile).toLowerCase();
1401
1835
  }
1402
1836
  const panelSteps = steps.slice(0, -1);
1403
1837
  if (panelSteps.some((step) => normalizeStatusLabel(step) === "running")) {
@@ -1408,11 +1842,23 @@ function deriveFusionStatusPhase(
1408
1842
  const status = normalizeStatusLabel(step);
1409
1843
  return status === "completed" || status === "failed";
1410
1844
  });
1411
- return allPanelsFinished ? "judge" : "panel";
1845
+ return allPanelsFinished
1846
+ ? synthesisStatusLabel(profile).toLowerCase()
1847
+ : "panel";
1412
1848
  }
1413
1849
  return "panel";
1414
1850
  }
1415
1851
 
1852
+ function synthesisStatusLabel(
1853
+ profile: Pick<FusionProfile, "panel" | "synthesis"> | undefined,
1854
+ fallback = false,
1855
+ ): "Judge" | "Fallback judge" | "Composer" | "Fallback composer" {
1856
+ const base = profile && resolveSynthesisMode(profile) === "merge"
1857
+ ? "Composer"
1858
+ : "Judge";
1859
+ return fallback ? `Fallback ${base.toLowerCase()}` as "Fallback judge" | "Fallback composer" : base;
1860
+ }
1861
+
1416
1862
  function buildCompletedPanelStatusLines(
1417
1863
  panel: FusionProfile["panel"],
1418
1864
  outputs: readonly PanelOutput[],
@@ -1570,20 +2016,26 @@ function findResultsArray(payload: unknown): readonly unknown[] | undefined {
1570
2016
  return undefined;
1571
2017
  }
1572
2018
 
2019
+ function hasExplicitResultsArray(payload: unknown): boolean {
2020
+ return findResultsArray(payload) !== undefined;
2021
+ }
2022
+
1573
2023
  function hasResultsArray(payload: unknown): boolean {
1574
2024
  return (findResultsArray(payload)?.length ?? 0) > 0;
1575
2025
  }
1576
2026
 
1577
2027
  function hasJudgeResult(payload: unknown, panelCount: number): boolean {
1578
- return (findResultsArray(payload)?.length ?? 0) > panelCount;
2028
+ const count =
2029
+ findResultsArray(payload)?.length ?? findStepsArray(payload).length;
2030
+ return count > panelCount;
1579
2031
  }
1580
2032
 
1581
2033
  function findResult(
1582
2034
  payload: unknown,
1583
2035
  resultIndex?: number,
1584
2036
  ): Record<string, unknown> | undefined {
1585
- const results = findResultsArray(payload);
1586
- if (!results || results.length === 0) return undefined;
2037
+ const results = findResultsArray(payload) ?? findStepsArray(payload);
2038
+ if (results.length === 0) return undefined;
1587
2039
  if (resultIndex !== undefined) {
1588
2040
  const indexed = results[resultIndex];
1589
2041
  return isRecord(indexed) ? indexed : undefined;
@@ -1678,6 +2130,31 @@ function extractWorkflowStoppedPanelIndices(payload: unknown): number[] {
1678
2130
  return [];
1679
2131
  }
1680
2132
 
2133
+ function withWorkflowDeadlineFailures(
2134
+ failures: readonly FailedPanelSummary[],
2135
+ deadlineError: string | undefined,
2136
+ ): FailedPanelSummary[] {
2137
+ if (!deadlineError) return [...failures];
2138
+ return failures.map((failure) => ({
2139
+ ...failure,
2140
+ summary: failure.summary.includes(deadlineError)
2141
+ ? failure.summary
2142
+ : `${deadlineError}\n${failure.summary}`,
2143
+ reason: failure.reason ?? "timeout",
2144
+ }));
2145
+ }
2146
+
2147
+ function isWorkflowDeadline(payload: unknown): boolean {
2148
+ if (!isRecord(payload)) return false;
2149
+ if (payload.timedOut === true) return true;
2150
+ const error = firstNonBlankString(payload.error, payload.errorMessage);
2151
+ if (error && /(?:workflow.*(?:timed out|timeout)|(?:timed out|timeout).*workflow)/i.test(error)) {
2152
+ return true;
2153
+ }
2154
+ if (isRecord(payload.details) && isWorkflowDeadline(payload.details)) return true;
2155
+ return isRecord(payload.data) ? isWorkflowDeadline(payload.data) : false;
2156
+ }
2157
+
1681
2158
  function extractSubagentFailure(payload: unknown): string | undefined {
1682
2159
  if (!isRecord(payload)) return undefined;
1683
2160
  const direct = firstNonBlankString(payload.error, payload.errorMessage);
@@ -1752,63 +2229,19 @@ function extractArtifactPath(
1752
2229
  return undefined;
1753
2230
  }
1754
2231
 
1755
- function mergePanelObservations(
1756
- result: ExtractPanelResultsSuccess,
1757
- statusPayload: unknown,
1758
- profile: FusionProfile,
1759
- ): ExtractPanelResultsSuccess {
1760
- const status = extractPanelResults(statusPayload, {
1761
- panel: profile.panel,
1762
- completedOnly: true,
1763
- limit: profile.panel.length,
1764
- });
1765
- if (!status.ok) return result;
1766
-
1767
- const observations = new Map<number, PanelOutput["observation"]>();
1768
- for (const output of status.outputs) {
1769
- observations.set(output.index, output.observation);
1770
- }
1771
- for (const failure of status.failures) {
1772
- observations.set(failure.index, failure.observation);
1773
- }
1774
-
1775
- return {
1776
- ...result,
1777
- outputs: result.outputs.map((output) =>
1778
- withMergedObservation(output, observations.get(output.index)),
1779
- ),
1780
- failures: result.failures.map((failure) =>
1781
- withMergedObservation(failure, observations.get(failure.index)),
1782
- ),
1783
- };
1784
- }
1785
-
1786
- function withMergedObservation<T extends PanelOutput | FailedPanelSummary>(
1787
- item: T,
1788
- statusObservation: PanelOutput["observation"] | undefined,
1789
- ): T {
1790
- const observation = mergeRunObservations(statusObservation, item.observation);
1791
- return hasObservationData(observation) ? { ...item, observation } : item;
1792
- }
1793
-
1794
- function hasObservationData(observation: PanelOutput["observation"]): boolean {
1795
- return Boolean(
1796
- observation &&
1797
- (observation.model ||
1798
- observation.durationMs !== undefined ||
1799
- observation.usage ||
1800
- observation.attempts ||
1801
- observation.providerFailures),
1802
- );
1803
- }
1804
-
1805
2232
  function shouldStopWhenPanelAgrees(
1806
2233
  profile: FusionProfile,
1807
2234
  outputs: readonly PanelOutput[],
1808
2235
  failures: readonly FailedPanelSummary[],
2236
+ persistedPolicy?: FusionRun["minimumSuccessfulPanelists"],
1809
2237
  ): boolean {
2238
+ const required = resolveMinimumSuccessfulPanelists(
2239
+ persistedPolicy ?? profile.minimumSuccessfulPanelists,
2240
+ profile.panel.length,
2241
+ );
1810
2242
  return (
1811
2243
  profile.stopWhenPanelAgrees === true &&
2244
+ outputs.length >= required &&
1812
2245
  hasStrongPanelAgreement(
1813
2246
  outputs,
1814
2247
  outputs.length + failures.length,