@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.
@@ -11,7 +11,10 @@ import {
11
11
  } from "./config.js";
12
12
  import { FusionArgsError } from "./errors.js";
13
13
  import { parseFusionArgs } from "./fusion-args.js";
14
- import { decidePanelCompletion } from "./panel-completion.js";
14
+ import {
15
+ decidePanelCompletion,
16
+ resolveMinimumSuccessfulPanelists,
17
+ } from "./panel-completion.js";
15
18
  import {
16
19
  renderCancelledReport,
17
20
  renderFailureReport,
@@ -25,8 +28,16 @@ import {
25
28
  reconcileIndexedLifecycleResult,
26
29
  reconcilePanelResults,
27
30
  } from "./lifecycle-reconcile.js";
28
- import { appendThinkingSuffix, buildPanelSpawnParams } from "./run-builder.js";
29
- import { FusionRunStore, FusionRunStoreError } from "./run-store.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";
30
41
  import {
31
42
  clearFusionUi,
32
43
  extractFusionProgressCounts,
@@ -45,6 +56,7 @@ import {
45
56
  resolveSynthesisMode,
46
57
  type FailedPanelSummary,
47
58
  type FusionProfile,
59
+ type FusionProfileSnapshot,
48
60
  type FusionRun,
49
61
  type PanelOutput,
50
62
  type ParsedFusionArgs,
@@ -113,6 +125,8 @@ export type FusionCommandResult =
113
125
  interface RunLifecycleSnapshot {
114
126
  statusPayload?: unknown;
115
127
  resultPayload?: unknown;
128
+ /** Compact event results need stable slots; status/artifacts are ordered snapshots. */
129
+ resultSource?: "artifact" | "event" | "status";
116
130
  resultIsTerminal: boolean;
117
131
  resultArtifactPending?: boolean;
118
132
  }
@@ -146,6 +160,11 @@ export class FusionOrchestrator {
146
160
  this.context = ctx;
147
161
 
148
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
+ }
149
168
  const existing = this.runStore.getActiveRun();
150
169
  if (existing) {
151
170
  const message = `Fusion run ${existing.id} is already active.`;
@@ -198,6 +217,7 @@ export class FusionOrchestrator {
198
217
 
199
218
  const outputContract =
200
219
  args.outputContract ?? detectCallerOutputContract(args.prompt);
220
+ const profileSnapshot = snapshotProfile(resolved.profile);
201
221
  let run: FusionRun;
202
222
  try {
203
223
  run = this.runStore.startRun({
@@ -210,10 +230,24 @@ export class FusionOrchestrator {
210
230
  ? { operationId: args.operationId }
211
231
  : {}),
212
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
+ ),
213
243
  phase: "panel",
214
244
  });
215
245
  } catch (error: unknown) {
216
- 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
+ }
217
251
  const active = this.runStore.getActiveRun();
218
252
  if (active) {
219
253
  this.notify(
@@ -225,12 +259,25 @@ export class FusionOrchestrator {
225
259
  }
226
260
  return { status: "failed", error: errorMessage(error) };
227
261
  }
228
- 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);
229
265
  publishFusionStatus(ctx, run);
230
266
 
231
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
+ });
232
274
  const spawnResult = await this.rpc.spawn(
233
- buildPanelSpawnParams(resolved.profile, args.prompt, outputContract),
275
+ buildPanelSpawnParams(
276
+ resolved.profile,
277
+ args.prompt,
278
+ outputContract,
279
+ args.timeoutOverrides,
280
+ ),
234
281
  );
235
282
  const spawnError = extractSubagentFailure(spawnResult);
236
283
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -251,10 +298,19 @@ export class FusionOrchestrator {
251
298
  ? { status: "cancelled", run: cancelled, report: cancelled.report }
252
299
  : { status: "ignored" };
253
300
  }
254
- const updated = this.runStore.updateRun(run.id, {
255
- panelRunId,
256
- ...(panelAsyncDir ? { panelAsyncDir } : {}),
257
- });
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
+ }
258
314
  publishFusionStatus(ctx, updated);
259
315
  this.ensureReconcileLoop();
260
316
  this.notify(
@@ -341,7 +397,7 @@ export class FusionOrchestrator {
341
397
  this.context,
342
398
  active,
343
399
  progress,
344
- deriveFusionStatusPhase(active, statusPayload),
400
+ deriveFusionStatusPhase(active, statusPayload, this.activeProfile),
345
401
  );
346
402
  }
347
403
  return statusPayload;
@@ -419,34 +475,72 @@ export class FusionOrchestrator {
419
475
  const summary = this.runStore.restoreFromSession(ctx);
420
476
  this.clearActiveRuntime();
421
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
+
422
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
+ }
423
493
  if (!active) {
424
494
  this.stopReconcileLoop();
425
495
  clearFusionUi(ctx);
426
496
  return summary;
427
497
  }
428
498
 
429
- try {
430
- const config = await this.loadConfig(ctx);
431
- // An inline run's profileName is a display name that no config defines,
432
- // so rebuild it from the base profile plus the persisted entries. Looking
433
- // the display name up throws, which used to leave activeProfile undefined
434
- // and fail the run the moment its panel completed.
435
- const base = this.resolveProfile(
436
- config,
437
- active.inlinePanel?.length
438
- ? active.baseProfileName
439
- : active.profileName,
440
- ).profile;
441
- this.activeProfile = active.inlinePanel?.length
442
- ? buildInlinePanelProfile(base, active.inlinePanel)
443
- : 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);
444
511
  this.configWarning = undefined;
445
- } catch (error: unknown) {
446
- const message = `Could not restore fusion profile "${active.profileName}": ${errorMessage(error)}`;
447
- this.configWarning = message;
448
- 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();
449
542
  }
543
+
450
544
  publishFusionStatus(ctx, active);
451
545
  this.ensureReconcileLoop();
452
546
  await this.reconcileActiveRun();
@@ -570,6 +664,7 @@ export class FusionOrchestrator {
570
664
  eventPayload: payload,
571
665
  });
572
666
  if (snapshot.resultArtifactPending) return { status: "ignored" };
667
+ this.persistVerifiedPanelResults(active, profile, snapshot.statusPayload);
573
668
  const terminalPayload =
574
669
  snapshot.resultPayload ?? snapshot.statusPayload ?? payload;
575
670
  if (
@@ -589,6 +684,9 @@ export class FusionOrchestrator {
589
684
  const extracted = extractPanelResults(lifecyclePayload, {
590
685
  panel: profile.panel,
591
686
  limit: profile.panel.length,
687
+ ...(snapshot.resultSource === "event"
688
+ ? { requireStableSlotIdentity: true }
689
+ : {}),
592
690
  });
593
691
  if (!extracted.ok) {
594
692
  return this.failActiveRun(
@@ -601,7 +699,13 @@ export class FusionOrchestrator {
601
699
  snapshot.statusPayload,
602
700
  profile,
603
701
  lifecyclePayload,
604
- { allowedTrailingResults: 1 },
702
+ {
703
+ allowedTrailingResults: 1,
704
+ ...(isWorkflowDeadline(lifecyclePayload) ||
705
+ isWorkflowDeadline(snapshot.statusPayload)
706
+ ? { terminalizeRunning: true }
707
+ : {}),
708
+ },
605
709
  );
606
710
  if (!observedPanels.ok) {
607
711
  return this.failActiveRun(
@@ -614,6 +718,26 @@ export class FusionOrchestrator {
614
718
  observedPanels.failures,
615
719
  );
616
720
 
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
+
617
741
  const judgePayload =
618
742
  hasJudgeResult(snapshot.resultPayload, profile.panel.length)
619
743
  ? snapshot.resultPayload
@@ -641,6 +765,7 @@ export class FusionOrchestrator {
641
765
  ),
642
766
  );
643
767
  const observed = this.runStore.updateRun(updated.id, {
768
+ completionQuality: completionQuality(profile, observedPanels.outputs, observedPanels.failures),
644
769
  judgeObservation,
645
770
  });
646
771
  const callerContract =
@@ -694,17 +819,23 @@ export class FusionOrchestrator {
694
819
  });
695
820
  if (snapshot.resultArtifactPending) return { status: "ignored" };
696
821
 
822
+ const partial = this.persistVerifiedPanelResults(
823
+ active,
824
+ profile,
825
+ snapshot.statusPayload,
826
+ );
697
827
  const panelIsTerminal =
698
828
  snapshot.resultIsTerminal ||
699
829
  isTerminalSubagentState(extractSubagentState(snapshot.statusPayload));
700
830
  if (!active.panelStopReason && !panelIsTerminal) {
701
- const partial = extractPanelResults(snapshot.statusPayload, {
702
- panel: profile.panel,
703
- completedOnly: true,
704
- });
705
831
  if (
706
- partial.ok &&
707
- shouldStopWhenPanelAgrees(profile, partial.outputs, partial.failures)
832
+ partial &&
833
+ shouldStopWhenPanelAgrees(
834
+ profile,
835
+ partial.outputs,
836
+ partial.failures,
837
+ active.minimumSuccessfulPanelists,
838
+ )
708
839
  ) {
709
840
  return this.stopPanelAfterAgreement(
710
841
  active,
@@ -733,6 +864,11 @@ export class FusionOrchestrator {
733
864
  const workflowStoppedIndices = extractWorkflowStoppedPanelIndices(
734
865
  lifecyclePayload,
735
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
+ }
736
872
  const stoppedPanelIndices =
737
873
  active.panelStoppedIndices ??
738
874
  (workflowStoppedIndices.length > 0 ? workflowStoppedIndices : undefined);
@@ -740,6 +876,9 @@ export class FusionOrchestrator {
740
876
  panel: profile.panel,
741
877
  limit: profile.panel.length,
742
878
  ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}),
879
+ ...(snapshot.resultSource === "event"
880
+ ? { requireStableSlotIdentity: true }
881
+ : {}),
743
882
  });
744
883
  if (!extracted.ok) {
745
884
  return this.failActiveRun(
@@ -752,17 +891,31 @@ export class FusionOrchestrator {
752
891
  snapshot.statusPayload,
753
892
  profile,
754
893
  lifecyclePayload,
755
- { ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}) },
894
+ {
895
+ ...(stoppedPanelIndices ? { stoppedPanelIndices } : {}),
896
+ ...(isWorkflowDeadline(lifecyclePayload) ||
897
+ isWorkflowDeadline(snapshot.statusPayload)
898
+ ? { terminalizeRunning: true }
899
+ : {}),
900
+ },
756
901
  );
757
902
  if (!observedPanels.ok) {
758
903
  return this.failActiveRun(
759
904
  `${observedPanels.error.message} (${observedPanels.error.path})`,
760
905
  );
761
906
  }
907
+ const reconciledFailures = withWorkflowDeadlineFailures(
908
+ observedPanels.failures,
909
+ isWorkflowDeadline(lifecyclePayload) ||
910
+ isWorkflowDeadline(snapshot.statusPayload)
911
+ ? extractSubagentFailure(snapshot.statusPayload) ??
912
+ extractSubagentFailure(lifecyclePayload)
913
+ : undefined,
914
+ );
762
915
  const stored = this.storePanelResults(
763
916
  active.id,
764
917
  observedPanels.outputs,
765
- observedPanels.failures,
918
+ reconciledFailures,
766
919
  );
767
920
  const updated =
768
921
  workflowStoppedIndices.length > 0 && !active.panelStopReason
@@ -776,7 +929,7 @@ export class FusionOrchestrator {
776
929
  updated,
777
930
  profile,
778
931
  observedPanels.outputs,
779
- observedPanels.failures,
932
+ reconciledFailures,
780
933
  { fallbackJudge: false },
781
934
  );
782
935
  }
@@ -854,10 +1007,16 @@ export class FusionOrchestrator {
854
1007
  return this.failActiveRun(decision.error, decision.report);
855
1008
  }
856
1009
  if (decision.kind === "complete") {
1010
+ this.runStore.updateRun(run.id, {
1011
+ completionQuality: completionQuality(profile, panelOutputs, panelFailures),
1012
+ });
857
1013
  return this.completeActiveRun(decision.report);
858
1014
  }
859
1015
 
860
1016
  try {
1017
+ this.runStore.updateRun(run.id, {
1018
+ spawnIntent: { stage: "judge", requestedAt: Date.now() },
1019
+ });
861
1020
  const spawnResult = await this.rpc.spawn(decision.params);
862
1021
  const spawnError = extractSubagentFailure(spawnResult);
863
1022
  if (spawnError) throw new FusionArgsError(spawnError);
@@ -870,13 +1029,23 @@ export class FusionOrchestrator {
870
1029
  await this.stopOrphanedRun(judgeRunId, "judge");
871
1030
  return { status: "ignored" };
872
1031
  }
873
- const nextRun = this.runStore.updateRun(run.id, {
874
- phase: "judge",
875
- judgeRunId,
876
- ...(judgeAsyncDir ? { judgeAsyncDir } : {}),
877
- panelOutputs: [...panelOutputs],
878
- panelFailures: [...panelFailures],
879
- });
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
+ }
880
1049
  publishFusionStatus(this.context, nextRun);
881
1050
  this.notify(
882
1051
  this.context,
@@ -1005,7 +1174,7 @@ export class FusionOrchestrator {
1005
1174
  this.context,
1006
1175
  input.run,
1007
1176
  progress,
1008
- deriveFusionStatusPhase(input.run, statusPayload),
1177
+ deriveFusionStatusPhase(input.run, statusPayload, this.activeProfile),
1009
1178
  );
1010
1179
  }
1011
1180
 
@@ -1015,23 +1184,20 @@ export class FusionOrchestrator {
1015
1184
  const eventIsTerminal =
1016
1185
  isTerminalSubagentState(extractSubagentState(input.eventPayload)) ||
1017
1186
  isTerminalFusionProgress(input.eventPayload);
1018
- const eventHasResults = hasResultsArray(input.eventPayload);
1019
- if (eventPayloadMatches && eventHasResults) {
1020
- return {
1021
- statusPayload,
1022
- resultPayload: input.eventPayload,
1023
- resultIsTerminal: eventIsTerminal || statusIsTerminal,
1024
- };
1025
- }
1026
-
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.
1027
1192
  const artifactResult = readSubagentResultArtifact({
1028
1193
  ...(input.runId ? { runId: input.runId } : {}),
1029
1194
  ...(input.asyncDir ? { asyncDir: input.asyncDir } : {}),
1030
1195
  });
1031
- if (hasResultsArray(artifactResult)) {
1196
+ if (hasExplicitResultsArray(artifactResult)) {
1032
1197
  return {
1033
- statusPayload,
1198
+ statusPayload: artifactResult,
1034
1199
  resultPayload: artifactResult,
1200
+ resultSource: "artifact",
1035
1201
  resultIsTerminal: true,
1036
1202
  };
1037
1203
  }
@@ -1047,10 +1213,20 @@ export class FusionOrchestrator {
1047
1213
  };
1048
1214
  }
1049
1215
 
1050
- 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)) {
1051
1226
  return {
1052
1227
  statusPayload,
1053
1228
  resultPayload: statusPayload,
1229
+ resultSource: "status",
1054
1230
  resultIsTerminal: statusIsTerminal,
1055
1231
  };
1056
1232
  }
@@ -1074,14 +1250,64 @@ export class FusionOrchestrator {
1074
1250
  return { statusPayload, resultIsTerminal: false };
1075
1251
  }
1076
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
+
1077
1272
  private storePanelResults(
1078
1273
  runId: string,
1079
1274
  panelOutputs: readonly PanelOutput[],
1080
1275
  panelFailures: readonly FailedPanelSummary[],
1081
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
+ }
1082
1308
  return this.runStore.updateRun(runId, {
1083
- panelOutputs: [...panelOutputs],
1084
- panelFailures: [...panelFailures],
1309
+ panelOutputs: mergedOutputs,
1310
+ panelFailures: mergedFailures,
1085
1311
  });
1086
1312
  }
1087
1313
 
@@ -1112,6 +1338,12 @@ export class FusionOrchestrator {
1112
1338
  ...(active.chainRunId ? { chainRunId: active.chainRunId } : {}),
1113
1339
  ...(active.panelRunId ? { panelRunId: active.panelRunId } : {}),
1114
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
+ },
1115
1347
  report,
1116
1348
  error,
1117
1349
  });
@@ -1199,6 +1431,124 @@ export class FusionOrchestrator {
1199
1431
  }
1200
1432
  }
1201
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
+
1202
1552
  export function extractSubagentRunId(payload: unknown): string | undefined {
1203
1553
  if (!isRecord(payload)) return undefined;
1204
1554
  const direct = firstNonBlankString(
@@ -1303,14 +1653,17 @@ function formatFusionStatusReport(input: {
1303
1653
  );
1304
1654
  lines.push(`Profile: ${input.active.profileName}`);
1305
1655
  lines.push(`Phase: ${input.details?.phaseLabel ?? input.active.phase}`);
1656
+ appendEffectiveTimeouts(lines, input.active);
1306
1657
  if (input.active.chainRunId)
1307
1658
  lines.push(`Chain run: ${input.active.chainRunId}`);
1308
1659
  else if (input.active.panelRunId)
1309
1660
  lines.push(`Panel run: ${input.active.panelRunId}`);
1310
1661
  if (input.active.judgeRunId) {
1311
- lines.push(
1312
- `${input.active.chainRunId ? "Fallback judge run" : "Judge run"}: ${input.active.judgeRunId}`,
1313
- );
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}`);
1314
1667
  }
1315
1668
  lines.push(
1316
1669
  `Progress: ${input.progress ? formatProgressCounts(input.progress) : "unknown"}`,
@@ -1322,6 +1675,7 @@ function formatFusionStatusReport(input: {
1322
1675
  lines.push(`Prompt: ${firstLine(input.last.prompt)}`);
1323
1676
  lines.push(`Profile: ${input.last.profileName}`);
1324
1677
  lines.push(`Phase: ${input.last.phase}`);
1678
+ appendEffectiveTimeouts(lines, input.last);
1325
1679
  if (input.last.chainRunId)
1326
1680
  lines.push(`Chain run: ${input.last.chainRunId}`);
1327
1681
  else if (input.last.panelRunId)
@@ -1343,6 +1697,20 @@ function formatFusionStatusReport(input: {
1343
1697
  return lines.join("\n");
1344
1698
  }
1345
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
+
1346
1714
  function appendStatusDetails(
1347
1715
  lines: string[],
1348
1716
  details: FusionStatusDetails | undefined,
@@ -1397,7 +1765,7 @@ function buildFusionStatusDetails(
1397
1765
  ): FusionStatusDetails {
1398
1766
  const details: FusionStatusDetails = {
1399
1767
  prompt: run.prompt,
1400
- phaseLabel: deriveFusionStatusPhase(run, payload),
1768
+ phaseLabel: deriveFusionStatusPhase(run, payload, profile),
1401
1769
  };
1402
1770
  if (!profile) return details;
1403
1771
 
@@ -1410,7 +1778,7 @@ function buildFusionStatusDetails(
1410
1778
  storedPanelFailures(run),
1411
1779
  );
1412
1780
  details.fallbackJudge = {
1413
- label: run.chainRunId ? "Fallback judge" : "Judge",
1781
+ label: synthesisStatusLabel(profile, Boolean(run.chainRunId)),
1414
1782
  ...(judgeModel ? { model: judgeModel } : {}),
1415
1783
  status: describeStandaloneRunStatus(payload),
1416
1784
  };
@@ -1439,7 +1807,7 @@ function buildFusionStatusDetails(
1439
1807
  const judgeActivity = describeStepActivity(steps[profile.panel.length]);
1440
1808
  const judgeMetrics = describeStepMetrics(steps[profile.panel.length]);
1441
1809
  details.judge = {
1442
- label: "Judge",
1810
+ label: synthesisStatusLabel(profile),
1443
1811
  ...(judgeModel ? { model: judgeModel } : {}),
1444
1812
  status: describeChainJudgeStatus(steps[profile.panel.length], panelists),
1445
1813
  ...([judgeActivity, judgeMetrics].filter(Boolean).length > 0
@@ -1452,9 +1820,10 @@ function buildFusionStatusDetails(
1452
1820
  function deriveFusionStatusPhase(
1453
1821
  run: Pick<FusionRun, "phase" | "chainRunId">,
1454
1822
  payload: unknown,
1823
+ profile?: Pick<FusionProfile, "panel" | "synthesis">,
1455
1824
  ): string {
1456
1825
  if (run.phase === "judge") {
1457
- return run.chainRunId ? "fallback judge" : "judge";
1826
+ return synthesisStatusLabel(profile, Boolean(run.chainRunId)).toLowerCase();
1458
1827
  }
1459
1828
  if (run.phase !== "chain") return run.phase;
1460
1829
 
@@ -1462,7 +1831,7 @@ function deriveFusionStatusPhase(
1462
1831
  if (steps.length === 0) return "chain";
1463
1832
  const judgeStatus = normalizeStatusLabel(steps.at(-1));
1464
1833
  if (judgeStatus === "running" || judgeStatus === "completed") {
1465
- return "judge";
1834
+ return synthesisStatusLabel(profile).toLowerCase();
1466
1835
  }
1467
1836
  const panelSteps = steps.slice(0, -1);
1468
1837
  if (panelSteps.some((step) => normalizeStatusLabel(step) === "running")) {
@@ -1473,11 +1842,23 @@ function deriveFusionStatusPhase(
1473
1842
  const status = normalizeStatusLabel(step);
1474
1843
  return status === "completed" || status === "failed";
1475
1844
  });
1476
- return allPanelsFinished ? "judge" : "panel";
1845
+ return allPanelsFinished
1846
+ ? synthesisStatusLabel(profile).toLowerCase()
1847
+ : "panel";
1477
1848
  }
1478
1849
  return "panel";
1479
1850
  }
1480
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
+
1481
1862
  function buildCompletedPanelStatusLines(
1482
1863
  panel: FusionProfile["panel"],
1483
1864
  outputs: readonly PanelOutput[],
@@ -1635,6 +2016,10 @@ function findResultsArray(payload: unknown): readonly unknown[] | undefined {
1635
2016
  return undefined;
1636
2017
  }
1637
2018
 
2019
+ function hasExplicitResultsArray(payload: unknown): boolean {
2020
+ return findResultsArray(payload) !== undefined;
2021
+ }
2022
+
1638
2023
  function hasResultsArray(payload: unknown): boolean {
1639
2024
  return (findResultsArray(payload)?.length ?? 0) > 0;
1640
2025
  }
@@ -1745,6 +2130,31 @@ function extractWorkflowStoppedPanelIndices(payload: unknown): number[] {
1745
2130
  return [];
1746
2131
  }
1747
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
+
1748
2158
  function extractSubagentFailure(payload: unknown): string | undefined {
1749
2159
  if (!isRecord(payload)) return undefined;
1750
2160
  const direct = firstNonBlankString(payload.error, payload.errorMessage);
@@ -1823,9 +2233,15 @@ function shouldStopWhenPanelAgrees(
1823
2233
  profile: FusionProfile,
1824
2234
  outputs: readonly PanelOutput[],
1825
2235
  failures: readonly FailedPanelSummary[],
2236
+ persistedPolicy?: FusionRun["minimumSuccessfulPanelists"],
1826
2237
  ): boolean {
2238
+ const required = resolveMinimumSuccessfulPanelists(
2239
+ persistedPolicy ?? profile.minimumSuccessfulPanelists,
2240
+ profile.panel.length,
2241
+ );
1827
2242
  return (
1828
2243
  profile.stopWhenPanelAgrees === true &&
2244
+ outputs.length >= required &&
1829
2245
  hasStrongPanelAgreement(
1830
2246
  outputs,
1831
2247
  outputs.length + failures.length,