@alexeiled/pi-fusion 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/agents/fusion-composer.md +3 -0
- package/agents/fusion-judge.md +3 -0
- package/agents/fusion-panelist.md +3 -0
- package/docs/user-guide.md +32 -11
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/caller-contract.ts +80 -0
- package/src/config.ts +58 -2
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +88 -13
- package/src/index.ts +33 -5
- package/src/lifecycle-reconcile.ts +445 -0
- package/src/orchestrator.ts +562 -129
- package/src/panel-completion.ts +105 -7
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +105 -3
- package/src/result-extract.ts +84 -7
- package/src/run-builder.ts +141 -15
- package/src/run-store.ts +377 -11
- package/src/status.ts +1 -1
- package/src/types.ts +89 -0
package/src/panel-completion.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
detectCallerOutputContract,
|
|
3
|
+
validateCallerOutput,
|
|
4
|
+
} from "./caller-contract.js";
|
|
5
|
+
import {
|
|
6
|
+
renderFailureReport,
|
|
7
|
+
renderPanelFailureReport,
|
|
8
|
+
renderPartialPanelReport,
|
|
9
|
+
renderSinglePanelReport,
|
|
10
|
+
} from "./report.js";
|
|
2
11
|
import {
|
|
3
12
|
appendThinkingSuffix,
|
|
4
13
|
buildJudgeSpawnParams,
|
|
5
14
|
type JudgeSpawnParams,
|
|
6
15
|
} from "./run-builder.js";
|
|
16
|
+
import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
|
|
7
17
|
import {
|
|
8
18
|
resolveSynthesisMode,
|
|
9
19
|
type FailedPanelSummary,
|
|
@@ -12,6 +22,8 @@ import {
|
|
|
12
22
|
type PanelOutput,
|
|
13
23
|
} from "./types.js";
|
|
14
24
|
|
|
25
|
+
export { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
|
|
26
|
+
|
|
15
27
|
export type PanelCompletionDecision =
|
|
16
28
|
| { kind: "fail"; error: string; report: string }
|
|
17
29
|
| { kind: "complete"; report: string }
|
|
@@ -50,14 +62,48 @@ export function decidePanelCompletion(
|
|
|
50
62
|
};
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
const intentionalStops =
|
|
66
|
+
input.profile.stopWhenPanelAgrees === true &&
|
|
67
|
+
input.panelOutputs.length >= 2 &&
|
|
68
|
+
input.panelFailures.length > 0 &&
|
|
69
|
+
input.panelFailures.every(
|
|
70
|
+
({ reason }) => reason === "stopped-after-agreement",
|
|
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.
|
|
57
80
|
if (
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
synthesis !== "merge" &&
|
|
82
|
+
input.profile.panel.length === 1 &&
|
|
83
|
+
input.panelOutputs.length === 1
|
|
60
84
|
) {
|
|
85
|
+
const callerContract =
|
|
86
|
+
input.run.outputContract ??
|
|
87
|
+
detectCallerOutputContract(input.run.prompt);
|
|
88
|
+
if (callerContract) {
|
|
89
|
+
const validation = validateCallerOutput(
|
|
90
|
+
callerContract,
|
|
91
|
+
input.panelOutputs[0]!.output,
|
|
92
|
+
);
|
|
93
|
+
if (!validation.ok) {
|
|
94
|
+
const report = renderFailureReport({
|
|
95
|
+
run: input.run,
|
|
96
|
+
error: validation.error,
|
|
97
|
+
panelOutputs: input.panelOutputs,
|
|
98
|
+
failures: input.panelFailures,
|
|
99
|
+
...withJudgeModel(judgeModel),
|
|
100
|
+
synthesis,
|
|
101
|
+
panel: input.profile.panel,
|
|
102
|
+
});
|
|
103
|
+
return { kind: "fail", error: validation.error, report };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
const report = renderSinglePanelReport({
|
|
62
108
|
run: input.run,
|
|
63
109
|
output: input.panelOutputs[0]!,
|
|
@@ -67,6 +113,49 @@ export function decidePanelCompletion(
|
|
|
67
113
|
return { kind: "complete", report };
|
|
68
114
|
}
|
|
69
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
|
+
|
|
70
159
|
return {
|
|
71
160
|
kind: "judge",
|
|
72
161
|
params: buildJudgeSpawnParams({
|
|
@@ -75,6 +164,15 @@ export function decidePanelCompletion(
|
|
|
75
164
|
panelOutputs: input.panelOutputs,
|
|
76
165
|
failedPanelists: input.panelFailures,
|
|
77
166
|
runId: input.run.id,
|
|
167
|
+
...(input.run.outputContract
|
|
168
|
+
? { callerContract: input.run.outputContract }
|
|
169
|
+
: {}),
|
|
170
|
+
...(input.run.timeoutOverrides
|
|
171
|
+
? { timeoutOverrides: input.run.timeoutOverrides }
|
|
172
|
+
: {}),
|
|
173
|
+
...(input.run.effectiveTimeouts
|
|
174
|
+
? { effectiveTimeouts: input.run.effectiveTimeouts }
|
|
175
|
+
: {}),
|
|
78
176
|
}),
|
|
79
177
|
missingRunIdError: input.fallbackJudge
|
|
80
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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
detectCallerOutputContract,
|
|
3
|
+
validateCallerOutput,
|
|
4
|
+
} from "./caller-contract.js";
|
|
1
5
|
import {
|
|
2
6
|
buildBlindLabelMap,
|
|
3
7
|
type FailedPanelSummary,
|
|
@@ -23,6 +27,10 @@ type ReportRun = Pick<
|
|
|
23
27
|
| "panelRunId"
|
|
24
28
|
| "judgeRunId"
|
|
25
29
|
| "panelStopReason"
|
|
30
|
+
| "outputContract"
|
|
31
|
+
| "completionQuality"
|
|
32
|
+
| "minimumSuccessfulPanelists"
|
|
33
|
+
| "effectiveTimeouts"
|
|
26
34
|
> &
|
|
27
35
|
Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
|
|
28
36
|
|
|
@@ -43,6 +51,16 @@ export interface RenderSinglePanelReportInput {
|
|
|
43
51
|
judgeModel?: string;
|
|
44
52
|
}
|
|
45
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
|
+
|
|
46
64
|
export interface RenderJudgeReportInput {
|
|
47
65
|
run: ReportRun;
|
|
48
66
|
judgeOutput: string;
|
|
@@ -118,9 +136,13 @@ interface AgentStatusOptions {
|
|
|
118
136
|
/** Lists the facets that no panelist covered, for a merge-mode failure. */
|
|
119
137
|
function formatUncoveredFacets(
|
|
120
138
|
panel?: readonly PanelMemberConfig[],
|
|
139
|
+
outputs: readonly PanelOutput[] = [],
|
|
121
140
|
): string | string[] {
|
|
122
141
|
if (!panel?.length) return "Every configured facet is uncovered.";
|
|
123
|
-
|
|
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) => {
|
|
124
146
|
const facet =
|
|
125
147
|
member.question?.trim() ?? member.role?.trim() ?? "the whole task";
|
|
126
148
|
return `- ${memberLabel(member)}: ${facet} (uncovered)`;
|
|
@@ -227,6 +249,9 @@ export function renderPanelFailureReport(
|
|
|
227
249
|
export function renderSinglePanelReport(
|
|
228
250
|
input: RenderSinglePanelReportInput,
|
|
229
251
|
): string {
|
|
252
|
+
const exactOutput = validExactCallerOutput(input.run, input.output.output);
|
|
253
|
+
if (exactOutput) return exactOutput;
|
|
254
|
+
|
|
230
255
|
const panelName = formatPanelName(input.output);
|
|
231
256
|
const sections: ReportSection[] = [
|
|
232
257
|
{
|
|
@@ -291,6 +316,15 @@ export function renderSinglePanelReport(
|
|
|
291
316
|
* Rewrites the neutral names the judge saw back to the configured member names.
|
|
292
317
|
* Longest label first so "Candidate AA" is not partly replaced by "Candidate A".
|
|
293
318
|
*/
|
|
319
|
+
function validExactCallerOutput(
|
|
320
|
+
run: ReportRun,
|
|
321
|
+
output: string,
|
|
322
|
+
): string | undefined {
|
|
323
|
+
const contract = run.outputContract ?? detectCallerOutputContract(run.prompt);
|
|
324
|
+
if (!contract) return undefined;
|
|
325
|
+
return validateCallerOutput(contract, output).ok ? output.trim() : undefined;
|
|
326
|
+
}
|
|
327
|
+
|
|
294
328
|
function restoreBlindLabels(
|
|
295
329
|
judgeOutput: string,
|
|
296
330
|
panelOutputs: readonly PanelOutput[],
|
|
@@ -314,9 +348,68 @@ function restoreBlindLabels(
|
|
|
314
348
|
return restored;
|
|
315
349
|
}
|
|
316
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
|
+
|
|
317
407
|
export function renderJudgeReport(input: RenderJudgeReportInput): string {
|
|
318
408
|
const panelOutputs = input.panelOutputs ?? [];
|
|
319
409
|
const failures = input.failures ?? [];
|
|
410
|
+
const exactOutput = validExactCallerOutput(input.run, input.judgeOutput);
|
|
411
|
+
if (exactOutput) return exactOutput;
|
|
412
|
+
|
|
320
413
|
const judgeOutput = input.blindPanelLabels
|
|
321
414
|
? restoreBlindLabels(input.judgeOutput, panelOutputs, failures)
|
|
322
415
|
: input.judgeOutput;
|
|
@@ -382,7 +475,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
|
|
|
382
475
|
const reportSections: ReportSection[] = [
|
|
383
476
|
{
|
|
384
477
|
title: "Summary",
|
|
385
|
-
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)),
|
|
386
482
|
},
|
|
387
483
|
{
|
|
388
484
|
title: "Agent Status",
|
|
@@ -392,6 +488,9 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
|
|
|
392
488
|
judgeStatus: "succeeded",
|
|
393
489
|
...(input.judgeModel ? { judgeModel: input.judgeModel } : {}),
|
|
394
490
|
...(input.synthesis ? { synthesis: input.synthesis } : {}),
|
|
491
|
+
...(input.run.completionQuality === "partial"
|
|
492
|
+
? { extra: ["- Completion quality: partial (incomplete coverage)"] }
|
|
493
|
+
: {}),
|
|
395
494
|
}),
|
|
396
495
|
},
|
|
397
496
|
...synthesisSections,
|
|
@@ -401,7 +500,10 @@ export function renderJudgeReport(input: RenderJudgeReportInput): string {
|
|
|
401
500
|
},
|
|
402
501
|
{
|
|
403
502
|
title: "Risks",
|
|
404
|
-
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."),
|
|
405
507
|
},
|
|
406
508
|
{
|
|
407
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
|
|
|
@@ -63,20 +67,50 @@ export function extractPanelResults(
|
|
|
63
67
|
|
|
64
68
|
const outputs: PanelOutput[] = [];
|
|
65
69
|
const failures: FailedPanelSummary[] = [];
|
|
70
|
+
const fallbackFailureReason = failureReason(container.payload);
|
|
66
71
|
const results =
|
|
67
72
|
options.limit === undefined
|
|
68
73
|
? container.results
|
|
69
74
|
: container.results.slice(0, options.limit);
|
|
70
|
-
|
|
75
|
+
const seenSlots = new Set<number>();
|
|
76
|
+
for (const [arrayIndex, rawResult] of results.entries()) {
|
|
71
77
|
if (options.completedOnly && !isCompletedResult(rawResult)) continue;
|
|
72
|
-
const
|
|
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);
|
|
101
|
+
const child = normalizeChildResult(
|
|
102
|
+
rawResult,
|
|
103
|
+
index,
|
|
104
|
+
options,
|
|
105
|
+
fallbackFailureReason,
|
|
106
|
+
);
|
|
73
107
|
if (!child.ok) return child;
|
|
74
108
|
if (child.status === "success") outputs.push(child.output);
|
|
75
109
|
else failures.push(child.failure);
|
|
76
110
|
}
|
|
77
111
|
|
|
78
112
|
for (const index of options.stoppedPanelIndices ?? []) {
|
|
79
|
-
if (index
|
|
113
|
+
if (seenSlots.has(index) || index >= (options.limit ?? Infinity)) continue;
|
|
80
114
|
const child = normalizeChildResult(
|
|
81
115
|
{
|
|
82
116
|
success: false,
|
|
@@ -84,12 +118,17 @@ export function extractPanelResults(
|
|
|
84
118
|
},
|
|
85
119
|
index,
|
|
86
120
|
options,
|
|
121
|
+
fallbackFailureReason,
|
|
87
122
|
);
|
|
88
123
|
if (!child.ok) return child;
|
|
89
124
|
if (child.status === "failed") failures.push(child.failure);
|
|
90
125
|
}
|
|
91
126
|
|
|
92
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);
|
|
93
132
|
return {
|
|
94
133
|
ok: true,
|
|
95
134
|
outputs,
|
|
@@ -193,10 +232,36 @@ function findResultsContainer(
|
|
|
193
232
|
);
|
|
194
233
|
}
|
|
195
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
|
+
|
|
196
260
|
function normalizeChildResult(
|
|
197
261
|
rawResult: unknown,
|
|
198
262
|
index: number,
|
|
199
263
|
options: ExtractPanelResultsOptions,
|
|
264
|
+
fallbackFailureReason?: FailedPanelSummary["reason"],
|
|
200
265
|
):
|
|
201
266
|
| { ok: true; status: "success"; output: PanelOutput }
|
|
202
267
|
| { ok: true; status: "failed"; failure: FailedPanelSummary }
|
|
@@ -222,7 +287,9 @@ function normalizeChildResult(
|
|
|
222
287
|
|
|
223
288
|
const artifactPath = extractArtifactPath(rawResult);
|
|
224
289
|
const sessionPath = firstString(rawResult.sessionPath, rawResult.sessionFile);
|
|
225
|
-
const
|
|
290
|
+
const terminalizedRunning =
|
|
291
|
+
options.terminalizeRunning === true && !isCompletedResult(rawResult);
|
|
292
|
+
const status = terminalizedRunning ? "failed" : classifyChildStatus(rawResult);
|
|
226
293
|
|
|
227
294
|
if (status === "success") {
|
|
228
295
|
const rawOutput = firstNonBlankString(
|
|
@@ -274,8 +341,13 @@ function normalizeChildResult(
|
|
|
274
341
|
agent,
|
|
275
342
|
summary: stoppedAfterAgreement
|
|
276
343
|
? "Stopped after strong panel agreement."
|
|
277
|
-
:
|
|
278
|
-
|
|
344
|
+
: terminalizedRunning
|
|
345
|
+
? "Panelist did not finish before the workflow deadline."
|
|
346
|
+
: failureSummary(rawResult, artifactPath),
|
|
347
|
+
reason:
|
|
348
|
+
(terminalizedRunning ? "timeout" : undefined) ??
|
|
349
|
+
failureReason(rawResult, stoppedAfterAgreement) ??
|
|
350
|
+
fallbackFailureReason,
|
|
279
351
|
observation,
|
|
280
352
|
artifactPath,
|
|
281
353
|
sessionPath,
|
|
@@ -392,7 +464,12 @@ function failureReason(
|
|
|
392
464
|
stoppedAfterAgreement = false,
|
|
393
465
|
): FailedPanelSummary["reason"] {
|
|
394
466
|
if (stoppedAfterAgreement) return "stopped-after-agreement";
|
|
395
|
-
if (
|
|
467
|
+
if (
|
|
468
|
+
result.timedOut === true ||
|
|
469
|
+
/(?:timed out|timeout)/i.test(firstNonBlankString(result.error) ?? "")
|
|
470
|
+
) {
|
|
471
|
+
return "timeout";
|
|
472
|
+
}
|
|
396
473
|
if (result.interrupted === true) return "interrupted";
|
|
397
474
|
return undefined;
|
|
398
475
|
}
|