@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.
@@ -5,6 +5,7 @@ import {
5
5
  import {
6
6
  renderFailureReport,
7
7
  renderPanelFailureReport,
8
+ renderPartialPanelReport,
8
9
  renderSinglePanelReport,
9
10
  } from "./report.js";
10
11
  import {
@@ -12,6 +13,7 @@ import {
12
13
  buildJudgeSpawnParams,
13
14
  type JudgeSpawnParams,
14
15
  } from "./run-builder.js";
16
+ import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
15
17
  import {
16
18
  resolveSynthesisMode,
17
19
  type FailedPanelSummary,
@@ -20,6 +22,8 @@ import {
20
22
  type PanelOutput,
21
23
  } from "./types.js";
22
24
 
25
+ export { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
26
+
23
27
  export type PanelCompletionDecision =
24
28
  | { kind: "fail"; error: string; report: string }
25
29
  | { kind: "complete"; report: string }
@@ -65,24 +69,19 @@ export function decidePanelCompletion(
65
69
  input.panelFailures.every(
66
70
  ({ reason }) => reason === "stopped-after-agreement",
67
71
  );
72
+ const synthesis = resolveSynthesisMode(input.profile);
73
+ const required = resolveMinimumSuccessfulPanelists(
74
+ input.run.minimumSuccessfulPanelists ??
75
+ input.profile.minimumSuccessfulPanelists,
76
+ input.profile.panel.length,
77
+ );
78
+ // A configured one-member panel is still validated as an exact caller
79
+ // contract, but does not need a synthetic comparison.
68
80
  if (
69
- input.panelOutputs.length < input.profile.panel.length &&
70
- !intentionalStops
81
+ synthesis !== "merge" &&
82
+ input.profile.panel.length === 1 &&
83
+ input.panelOutputs.length === 1
71
84
  ) {
72
- const error = `Only ${input.panelOutputs.length} of ${input.profile.panel.length} fusion panelists completed successfully; ${input.panelFailures.length} panelist result(s) are also missing.`;
73
- const report = renderFailureReport({
74
- run: input.run,
75
- error,
76
- panelOutputs: input.panelOutputs,
77
- failures: input.panelFailures,
78
- ...withJudgeModel(judgeModel),
79
- synthesis: resolveSynthesisMode(input.profile),
80
- panel: input.profile.panel,
81
- });
82
- return { kind: "fail", error, report };
83
- }
84
-
85
- if (input.panelOutputs.length === 1) {
86
85
  const callerContract =
87
86
  input.run.outputContract ??
88
87
  detectCallerOutputContract(input.run.prompt);
@@ -98,7 +97,7 @@ export function decidePanelCompletion(
98
97
  panelOutputs: input.panelOutputs,
99
98
  failures: input.panelFailures,
100
99
  ...withJudgeModel(judgeModel),
101
- synthesis: resolveSynthesisMode(input.profile),
100
+ synthesis,
102
101
  panel: input.profile.panel,
103
102
  });
104
103
  return { kind: "fail", error: validation.error, report };
@@ -114,6 +113,49 @@ export function decidePanelCompletion(
114
113
  return { kind: "complete", report };
115
114
  }
116
115
 
116
+ // Synthesis needs two candidates for select mode. A lower configured quorum
117
+ // still produces a useful, explicitly unsynthesized partial report instead
118
+ // of pretending that one panelist is a panel.
119
+ if (
120
+ (input.panelOutputs.length < required && !intentionalStops) ||
121
+ input.panelOutputs.length < 2
122
+ ) {
123
+ const report = renderPartialPanelReport({
124
+ run: { ...input.run, completionQuality: "partial" },
125
+ panelOutputs: input.panelOutputs,
126
+ failures: input.panelFailures,
127
+ required,
128
+ synthesis,
129
+ panel: input.profile.panel,
130
+ ...withJudgeModel(judgeModel),
131
+ });
132
+ const callerContract =
133
+ input.run.outputContract ?? detectCallerOutputContract(input.run.prompt);
134
+ if (callerContract) {
135
+ // A partial report must disclose its incomplete coverage, but that prose
136
+ // is forbidden by exact caller contracts. Do not publish a report that
137
+ // merely looks successful while violating the caller's protocol.
138
+ const validation = validateCallerOutput(callerContract, report);
139
+ if (!validation.ok) {
140
+ const error = `${validation.error} Fusion could not synthesize a contract-compliant result from below-quorum panel coverage.`;
141
+ return {
142
+ kind: "fail",
143
+ error,
144
+ report: renderFailureReport({
145
+ run: input.run,
146
+ error,
147
+ panelOutputs: input.panelOutputs,
148
+ failures: input.panelFailures,
149
+ ...withJudgeModel(judgeModel),
150
+ synthesis,
151
+ panel: input.profile.panel,
152
+ }),
153
+ };
154
+ }
155
+ }
156
+ return { kind: "complete", report };
157
+ }
158
+
117
159
  return {
118
160
  kind: "judge",
119
161
  params: buildJudgeSpawnParams({
@@ -125,6 +167,12 @@ export function decidePanelCompletion(
125
167
  ...(input.run.outputContract
126
168
  ? { callerContract: input.run.outputContract }
127
169
  : {}),
170
+ ...(input.run.timeoutOverrides
171
+ ? { timeoutOverrides: input.run.timeoutOverrides }
172
+ : {}),
173
+ ...(input.run.effectiveTimeouts
174
+ ? { effectiveTimeouts: input.run.effectiveTimeouts }
175
+ : {}),
128
176
  }),
129
177
  missingRunIdError: input.fallbackJudge
130
178
  ? "pi-subagents spawn did not return a fallback judge run ID."
@@ -0,0 +1,22 @@
1
+ import type { MinimumSuccessfulPanelists } from "./types.js";
2
+
3
+ /**
4
+ * Resolves a configured panel-success policy to the number of successful
5
+ * panelists required for synthesis and agreement stopping.
6
+ */
7
+ export function resolveMinimumSuccessfulPanelists(
8
+ policy: MinimumSuccessfulPanelists | undefined,
9
+ panelSize: number,
10
+ ): number {
11
+ if (policy === "all") return panelSize;
12
+ if (typeof policy === "number") {
13
+ // A multi-member synthesis cannot truthfully claim a panel conclusion from
14
+ // one answer. Preserve one-member panels while making legacy numeric `1`
15
+ // behave as the minimum meaningful two-candidate quorum.
16
+ return panelSize > 1 ? Math.max(2, Math.min(policy, panelSize)) : 1;
17
+ }
18
+ // Fusion uses a quorum (half rounded up), not an absolute strict-majority
19
+ // vote: two independent completed answers are enough to synthesize a
20
+ // four-member panel while still requiring two of three.
21
+ return Math.ceil(panelSize / 2);
22
+ }
package/src/report.ts CHANGED
@@ -28,6 +28,9 @@ type ReportRun = Pick<
28
28
  | "judgeRunId"
29
29
  | "panelStopReason"
30
30
  | "outputContract"
31
+ | "completionQuality"
32
+ | "minimumSuccessfulPanelists"
33
+ | "effectiveTimeouts"
31
34
  > &
32
35
  Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
33
36
 
@@ -48,6 +51,16 @@ export interface RenderSinglePanelReportInput {
48
51
  judgeModel?: string;
49
52
  }
50
53
 
54
+ export interface RenderPartialPanelReportInput {
55
+ run: ReportRun;
56
+ panelOutputs: readonly PanelOutput[];
57
+ failures: readonly FailedPanelSummary[];
58
+ required: number;
59
+ synthesis: FusionSynthesisMode;
60
+ panel: readonly PanelMemberConfig[];
61
+ judgeModel?: string;
62
+ }
63
+
51
64
  export interface RenderJudgeReportInput {
52
65
  run: ReportRun;
53
66
  judgeOutput: string;
@@ -123,9 +136,13 @@ interface AgentStatusOptions {
123
136
  /** Lists the facets that no panelist covered, for a merge-mode failure. */
124
137
  function formatUncoveredFacets(
125
138
  panel?: readonly PanelMemberConfig[],
139
+ outputs: readonly PanelOutput[] = [],
126
140
  ): string | string[] {
127
141
  if (!panel?.length) return "Every configured facet is uncovered.";
128
- return panel.map((member) => {
142
+ const covered = new Set(outputs.map((output) => output.index));
143
+ const missing = panel.filter((_member, index) => !covered.has(index));
144
+ if (missing.length === 0) return "No configured facets are uncovered.";
145
+ return missing.map((member) => {
129
146
  const facet =
130
147
  member.question?.trim() ?? member.role?.trim() ?? "the whole task";
131
148
  return `- ${memberLabel(member)}: ${facet} (uncovered)`;
@@ -331,6 +348,62 @@ function restoreBlindLabels(
331
348
  return restored;
332
349
  }
333
350
 
351
+ export function renderPartialPanelReport(
352
+ input: RenderPartialPanelReportInput,
353
+ ): string {
354
+ const succeeded = input.panelOutputs.length;
355
+ const merger = input.synthesis === "merge";
356
+ const partial = `Partial panel coverage: ${succeeded} successful panelist(s), below the required quorum of ${input.required}. No ${merger ? "composer" : "judge"} synthesis was run.`;
357
+ const candidateText = input.panelOutputs
358
+ .map((output) => `### ${formatPanelName(output)}\n${output.output.trim()}`)
359
+ .join("\n\n");
360
+ const sections: ReportSection[] = [
361
+ { title: "Summary", content: partial },
362
+ {
363
+ title: "Agent Status",
364
+ content: formatAgentStatus({
365
+ panelOutputs: input.panelOutputs,
366
+ failures: input.failures,
367
+ judgeStatus: `not run - below quorum (${succeeded}/${input.required})`,
368
+ ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
369
+ synthesis: input.synthesis,
370
+ extra: ["- Completion quality: partial"],
371
+ }),
372
+ },
373
+ ...(merger
374
+ ? [
375
+ { title: "Coverage Map" as const, content: "Partial coverage only; surviving facet outputs are listed below." },
376
+ { title: "Combined Answer" as const, content: candidateText || "No usable panel output." },
377
+ {
378
+ title: "Gaps" as const,
379
+ content: formatUncoveredFacets(input.panel, input.panelOutputs),
380
+ },
381
+ { title: "Conflicts At Seams" as const, content: "Not synthesized because the composer quorum was not met." },
382
+ ]
383
+ : [
384
+ { title: "Consensus" as const, content: "Not synthesized because the panel quorum was not met." },
385
+ { title: "Disagreements" as const, content: "Not synthesized because the judge did not run." },
386
+ { title: "Unique Insights" as const, content: candidateText || "No usable panel output." },
387
+ { title: "Blind Spots" as const, content: "Unavailable perspectives and absent cross-panel synthesis can hide important issues." },
388
+ ]),
389
+ { title: "Recommendation", content: "Use the surviving panel output as incomplete evidence, not a final fusion recommendation." },
390
+ {
391
+ title: "Risks",
392
+ content: `Coverage is incomplete; ${input.failures.length} panelist(s) were unavailable${input.failures.some((failure) => failure.reason === "timeout") ? " (including timeout failures)" : ""}. Fusion did not retry any panelist.`,
393
+ },
394
+ { title: "Next Step", content: "Inspect the unavailable perspectives; after this terminal run, manually start a new /fusion run if full coverage is needed." },
395
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
396
+ ];
397
+ const runDetails = formatRunDetails({
398
+ panelOutputs: input.panelOutputs,
399
+ failures: input.failures,
400
+ ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
401
+ synthesis: input.synthesis,
402
+ });
403
+ if (runDetails) sections.splice(-1, 0, runDetails);
404
+ return renderReport(sections);
405
+ }
406
+
334
407
  export function renderJudgeReport(input: RenderJudgeReportInput): string {
335
408
  const panelOutputs = input.panelOutputs ?? [];
336
409
  const failures = input.failures ?? [];
@@ -402,7 +475,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
402
475
  const reportSections: ReportSection[] = [
403
476
  {
404
477
  title: "Summary",
405
- content: sections.get("Summary") ?? judgeSummary(panelOutputs, failures),
478
+ content:
479
+ input.run.completionQuality === "partial"
480
+ ? `Partial panel coverage: ${panelOutputs.length} successful panelist(s) and ${failures.length} unavailable perspective(s) were synthesized. ${sections.get("Summary") ?? ""}`.trim()
481
+ : (sections.get("Summary") ?? judgeSummary(panelOutputs, failures)),
406
482
  },
407
483
  {
408
484
  title: "Agent Status",
@@ -412,6 +488,9 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
412
488
  judgeStatus: "succeeded",
413
489
  ...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
414
490
  ...(input.synthesis ? { synthesis: input.synthesis } : {}),
491
+ ...(input.run.completionQuality === "partial"
492
+ ? { extra: ["- Completion quality: partial (incomplete coverage)"] }
493
+ : {}),
415
494
  }),
416
495
  },
417
496
  ...synthesisSections,
@@ -421,7 +500,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
421
500
  },
422
501
  {
423
502
  title: "Risks",
424
- content: sections.get("Risks") ?? "Not specified by the judge.",
503
+ content:
504
+ input.run.completionQuality === "partial"
505
+ ? `Incomplete coverage: unavailable panel perspectives${failures.some((failure) => failure.reason === "timeout") ? " include timeout failures" : ""}. ${sections.get("Risks") ?? ""}`.trim()
506
+ : (sections.get("Risks") ?? "Not specified by the judge."),
425
507
  },
426
508
  {
427
509
  title: "Next Step",
@@ -22,6 +22,10 @@ export interface ExtractPanelResultsOptions {
22
22
  panel?: readonly PanelMemberConfig[];
23
23
  limit?: number;
24
24
  completedOnly?: boolean;
25
+ /** On a terminal workflow deadline, running slots become typed failures. */
26
+ terminalizeRunning?: boolean;
27
+ /** Compact events need an explicit public workflow slot, never array order. */
28
+ requireStableSlotIdentity?: boolean;
25
29
  stoppedPanelIndices?: readonly number[];
26
30
  }
27
31
 
@@ -68,8 +72,32 @@ export function extractPanelResults(
68
72
  options.limit === undefined
69
73
  ? container.results
70
74
  : container.results.slice(0, options.limit);
71
- for (const [index, rawResult] of results.entries()) {
75
+ const seenSlots = new Set<number>();
76
+ for (const [arrayIndex, rawResult] of results.entries()) {
72
77
  if (options.completedOnly && !isCompletedResult(rawResult)) continue;
78
+ const index = workflowSlotIndex(rawResult, arrayIndex, options);
79
+ if (index === undefined) {
80
+ return error(
81
+ "missing-result-field",
82
+ "Compact subagents result omitted a stable workflow slot identity.",
83
+ `${container.path}[${arrayIndex}]`,
84
+ );
85
+ }
86
+ if (options.limit !== undefined && index >= options.limit) {
87
+ return error(
88
+ "unknown-result-shape",
89
+ "Subagents result workflow slot is outside the configured panel.",
90
+ `${container.path}[${arrayIndex}]`,
91
+ );
92
+ }
93
+ if (seenSlots.has(index)) {
94
+ return error(
95
+ "unknown-result-shape",
96
+ "Subagents result repeated a workflow slot identity.",
97
+ `${container.path}[${arrayIndex}]`,
98
+ );
99
+ }
100
+ seenSlots.add(index);
73
101
  const child = normalizeChildResult(
74
102
  rawResult,
75
103
  index,
@@ -82,7 +110,7 @@ export function extractPanelResults(
82
110
  }
83
111
 
84
112
  for (const index of options.stoppedPanelIndices ?? []) {
85
- if (index < results.length || index >= (options.limit ?? Infinity)) continue;
113
+ if (seenSlots.has(index) || index >= (options.limit ?? Infinity)) continue;
86
114
  const child = normalizeChildResult(
87
115
  {
88
116
  success: false,
@@ -97,6 +125,10 @@ export function extractPanelResults(
97
125
  }
98
126
 
99
127
  const runId = firstString(container.payload.runId, container.payload.id);
128
+ // Stable slot identity also gives consumers configuration order independent
129
+ // of compact-event ordering.
130
+ outputs.sort((left, right) => left.index - right.index);
131
+ failures.sort((left, right) => left.index - right.index);
100
132
  return {
101
133
  ok: true,
102
134
  outputs,
@@ -200,6 +232,31 @@ function findResultsContainer(
200
232
  );
201
233
  }
202
234
 
235
+ function workflowSlotIndex(
236
+ rawResult: unknown,
237
+ fallback: number,
238
+ options: ExtractPanelResultsOptions,
239
+ ): number | undefined {
240
+ if (!options.requireStableSlotIdentity) return fallback;
241
+ if (!isRecord(rawResult)) return undefined;
242
+ for (const candidate of [rawResult.index, rawResult.taskIndex, rawResult.stepIndex]) {
243
+ if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
244
+ return candidate;
245
+ }
246
+ }
247
+ const key = firstString(
248
+ rawResult.key,
249
+ rawResult.taskKey,
250
+ rawResult.stepKey,
251
+ rawResult.agent,
252
+ );
253
+ // Workflow scripts name their public child slots panel-1, panel-2, etc.
254
+ // Never infer an omitted compact-event slot from its array position: compact
255
+ // completion events can be reordered or omit failed children.
256
+ const match = key?.match(/^panel-([1-9]\d*)$/);
257
+ return match ? Number(match[1]) - 1 : undefined;
258
+ }
259
+
203
260
  function normalizeChildResult(
204
261
  rawResult: unknown,
205
262
  index: number,
@@ -230,7 +287,9 @@ function normalizeChildResult(
230
287
 
231
288
  const artifactPath = extractArtifactPath(rawResult);
232
289
  const sessionPath = firstString(rawResult.sessionPath, rawResult.sessionFile);
233
- const status = classifyChildStatus(rawResult);
290
+ const terminalizedRunning =
291
+ options.terminalizeRunning === true && !isCompletedResult(rawResult);
292
+ const status = terminalizedRunning ? "failed" : classifyChildStatus(rawResult);
234
293
 
235
294
  if (status === "success") {
236
295
  const rawOutput = firstNonBlankString(
@@ -282,8 +341,11 @@ function normalizeChildResult(
282
341
  agent,
283
342
  summary: stoppedAfterAgreement
284
343
  ? "Stopped after strong panel agreement."
285
- : failureSummary(rawResult, artifactPath),
344
+ : terminalizedRunning
345
+ ? "Panelist did not finish before the workflow deadline."
346
+ : failureSummary(rawResult, artifactPath),
286
347
  reason:
348
+ (terminalizedRunning ? "timeout" : undefined) ??
287
349
  failureReason(rawResult, stoppedAfterAgreement) ??
288
350
  fallbackFailureReason,
289
351
  observation,
@@ -2,6 +2,8 @@ import {
2
2
  callerOutputContractInstructions,
3
3
  detectCallerOutputContract,
4
4
  } from "./caller-contract.js";
5
+ import { FusionArgsError } from "./errors.js";
6
+ import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
5
7
  import {
6
8
  PANEL_DECISION_CLOSE,
7
9
  PANEL_DECISION_OPEN,
@@ -13,6 +15,8 @@ import {
13
15
  panelItemLabel,
14
16
  resolveSynthesisMode,
15
17
  type CallerOutputContract,
18
+ type EffectiveFusionTimeouts,
19
+ type FusionTimeoutOverrides,
16
20
  THINKING_LEVELS,
17
21
  type FailedPanelSummary,
18
22
  type FusionProfile,
@@ -31,6 +35,8 @@ export const FUSION_ACCEPTANCE_DISABLED = {
31
35
  export type FusionAcceptanceDisabled = typeof FUSION_ACCEPTANCE_DISABLED;
32
36
 
33
37
  const DEFAULT_STAGE_TIMEOUT_MS = 900_000;
38
+ const DEFAULT_PANELIST_TIMEOUT_MS = 840_000;
39
+ const DEFAULT_PANEL_GRACE_MS = 5_000;
34
40
  const DEFAULT_TOOL_BUDGET: ToolBudget = {
35
41
  soft: 8,
36
42
  hard: 12,
@@ -48,6 +54,8 @@ export interface PanelSubagentTaskParams {
48
54
  model?: string;
49
55
  /** Per-task cap so a panelist finalises before the workflow timeout. */
50
56
  toolBudget: ToolBudget;
57
+ /** Child deadline, always shorter than the enclosing panel workflow. */
58
+ timeoutMs?: number;
51
59
  }
52
60
 
53
61
  export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
@@ -100,6 +108,9 @@ export interface BuildJudgeSpawnParamsInput {
100
108
  */
101
109
  runId: string;
102
110
  callerContract?: CallerOutputContract;
111
+ timeoutOverrides?: FusionTimeoutOverrides;
112
+ /** Persisted at panel start so restored fallback judges keep their deadline. */
113
+ effectiveTimeouts?: EffectiveFusionTimeouts;
103
114
  }
104
115
 
105
116
  const PANEL_OUTPUT_CONTRACT = [
@@ -168,7 +179,9 @@ export function buildPanelSpawnParams(
168
179
  profile: FusionProfile,
169
180
  prompt: string,
170
181
  callerContract?: CallerOutputContract,
182
+ timeoutOverrides?: FusionTimeoutOverrides,
171
183
  ): PanelSpawnParams {
184
+ const timeouts = resolveEffectiveTimeouts(profile, timeoutOverrides);
172
185
  const concurrency = profile.concurrency ?? profile.panel.length;
173
186
  const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
174
187
  (member, index) => ({
@@ -179,22 +192,29 @@ export function buildPanelSpawnParams(
179
192
  profile.stopWhenPanelAgrees === true,
180
193
  profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
181
194
  callerContract,
195
+ timeouts.panelistTimeoutMs,
182
196
  ),
183
197
  }),
184
198
  );
185
199
 
200
+ const requiredSuccessfulPanelists = resolveMinimumSuccessfulPanelists(
201
+ profile.minimumSuccessfulPanelists,
202
+ profile.panel.length,
203
+ );
204
+
186
205
  return {
187
206
  workflowScript: buildPanelWorkflowScript(
188
207
  tasks,
189
208
  concurrency,
190
209
  profile.stopWhenPanelAgrees === true,
210
+ requiredSuccessfulPanelists,
191
211
  ),
192
212
  async: true,
193
213
  context: profile.context ?? "fresh",
194
214
  output: true,
195
215
  outputMode: "inline",
196
216
  acceptance: FUSION_ACCEPTANCE_DISABLED,
197
- timeoutMs: resolveStageTimeout(profile.panelTimeoutMs, profile.timeoutMs),
217
+ timeoutMs: timeouts.panelTimeoutMs,
198
218
  };
199
219
  }
200
220
 
@@ -223,10 +243,9 @@ export function buildJudgeSpawnParams(
223
243
  output: true,
224
244
  outputMode: "inline",
225
245
  acceptance: FUSION_ACCEPTANCE_DISABLED,
226
- timeoutMs: resolveStageTimeout(
227
- input.profile.judgeTimeoutMs,
228
- input.profile.timeoutMs,
229
- ),
246
+ timeoutMs:
247
+ input.effectiveTimeouts?.judgeTimeoutMs ??
248
+ resolveEffectiveTimeouts(input.profile, input.timeoutOverrides).judgeTimeoutMs,
230
249
  };
231
250
  }
232
251
 
@@ -234,13 +253,18 @@ function buildPanelWorkflowScript(
234
253
  tasks: readonly PanelWorkflowTaskParams[],
235
254
  concurrency: number,
236
255
  stopWhenAgrees: boolean,
256
+ requiredSuccessfulPanelists: number,
237
257
  ): string {
238
258
  const serializedTasks = JSON.stringify(tasks);
259
+ // Start no more work than the resolved quorum requires. This preserves the
260
+ // two-at-a-time majority behavior while allowing a larger configured quorum
261
+ // to be observed before agreement can stop the remaining panelists.
239
262
  const effectiveConcurrency = stopWhenAgrees
240
- ? Math.min(concurrency, 2)
263
+ ? Math.min(concurrency, requiredSuccessfulPanelists)
241
264
  : concurrency;
242
265
  const stopLogic = stopWhenAgrees
243
266
  ? [
267
+ `const requiredSuccessfulPanelists = ${requiredSuccessfulPanelists};`,
244
268
  "const decisions = results",
245
269
  " .filter((result) => result && result.ok === true)",
246
270
  " .map((result) => {",
@@ -250,7 +274,7 @@ function buildPanelWorkflowScript(
250
274
  " try { return JSON.parse(match[1]); } catch { return undefined; }",
251
275
  " })",
252
276
  " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
253
- "if (decisions.length >= 2 && results.length < tasks.length) {",
277
+ "if (decisions.length >= requiredSuccessfulPanelists && results.length < tasks.length) {",
254
278
  " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
255
279
  " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
256
280
  " if (agrees) {",
@@ -278,8 +302,58 @@ function buildPanelWorkflowScript(
278
302
  function resolveStageTimeout(
279
303
  stageTimeoutMs: number | undefined,
280
304
  legacyTimeoutMs: number | undefined,
305
+ defaultTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
281
306
  ): number {
282
- return stageTimeoutMs ?? legacyTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
307
+ return stageTimeoutMs ?? legacyTimeoutMs ?? defaultTimeoutMs;
308
+ }
309
+
310
+ /** Resolves and records the deadline precedence used for one start attempt. */
311
+ export function resolveEffectiveTimeouts(
312
+ profile: FusionProfile,
313
+ overrides: FusionTimeoutOverrides | undefined = undefined,
314
+ ): EffectiveFusionTimeouts {
315
+ const panelTimeoutMs = resolveStageTimeout(
316
+ overrides?.panelTimeoutMs ?? profile.panelTimeoutMs,
317
+ profile.timeoutMs,
318
+ );
319
+ const panelGraceMs = resolveStageTimeout(
320
+ overrides?.panelGraceMs ?? profile.panelGraceMs,
321
+ undefined,
322
+ DEFAULT_PANEL_GRACE_MS,
323
+ );
324
+ const requestedPanelistTimeoutMs = resolveStageTimeout(
325
+ overrides?.panelistTimeoutMs ?? profile.panelistTimeoutMs,
326
+ profile.timeoutMs,
327
+ DEFAULT_PANELIST_TIMEOUT_MS,
328
+ );
329
+ if (panelGraceMs >= panelTimeoutMs) {
330
+ throw new FusionArgsError(
331
+ `panelGraceMs (${panelGraceMs}ms) must be shorter than panelTimeoutMs (${panelTimeoutMs}ms).`,
332
+ );
333
+ }
334
+ // A child must conclude before the enclosing workflow. The validated grace
335
+ // interval guarantees this cap remains a meaningful deadline.
336
+ const panelistTimeoutMs = Math.min(
337
+ requestedPanelistTimeoutMs,
338
+ panelTimeoutMs - panelGraceMs,
339
+ );
340
+ return {
341
+ panelistTimeoutMs,
342
+ panelTimeoutMs,
343
+ panelGraceMs,
344
+ judgeTimeoutMs: resolveStageTimeout(
345
+ overrides?.judgeTimeoutMs ?? profile.judgeTimeoutMs,
346
+ profile.timeoutMs,
347
+ ),
348
+ usesLegacyTimeout:
349
+ profile.timeoutMs !== undefined &&
350
+ (overrides?.panelistTimeoutMs === undefined &&
351
+ profile.panelistTimeoutMs === undefined ||
352
+ overrides?.panelTimeoutMs === undefined &&
353
+ profile.panelTimeoutMs === undefined ||
354
+ overrides?.judgeTimeoutMs === undefined &&
355
+ profile.judgeTimeoutMs === undefined),
356
+ };
283
357
  }
284
358
 
285
359
  function buildPanelTaskParams(
@@ -288,6 +362,7 @@ function buildPanelTaskParams(
288
362
  includeDecisionRecord: boolean,
289
363
  toolBudget: ToolBudget,
290
364
  callerContract?: CallerOutputContract,
365
+ timeoutMs?: number,
291
366
  ): PanelSubagentTaskParams {
292
367
  const model = appendThinkingSuffix(member.model, member.thinking);
293
368
  return {
@@ -304,6 +379,7 @@ function buildPanelTaskParams(
304
379
  skill: false,
305
380
  acceptance: FUSION_ACCEPTANCE_DISABLED,
306
381
  toolBudget,
382
+ ...(timeoutMs ? { timeoutMs } : {}),
307
383
  ...(model ? { model } : {}),
308
384
  };
309
385
  }