@alexeiled/pi-fusion 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/docs/user-guide.md +15 -12
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/config.ts +26 -0
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +29 -0
- package/src/index.ts +33 -5
- package/src/lifecycle-reconcile.ts +203 -6
- package/src/orchestrator.ts +487 -71
- package/src/panel-completion.ts +65 -17
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +85 -3
- package/src/result-extract.ts +66 -4
- package/src/run-builder.ts +84 -8
- package/src/run-store.ts +360 -11
- package/src/status.ts +1 -1
- package/src/types.ts +75 -0
package/src/panel-completion.ts
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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",
|
package/src/result-extract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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,
|
package/src/run-builder.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
227
|
-
input.
|
|
228
|
-
input.profile.
|
|
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,
|
|
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 >=
|
|
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 ??
|
|
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
|
}
|