@alexeiled/pi-fusion 0.7.0 → 0.9.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 +8 -3
- package/agents/fusion-panelist-full.md +3 -1
- package/agents/fusion-panelist-web.md +3 -1
- package/agents/fusion-panelist.md +3 -1
- package/docs/user-guide.md +56 -12
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/commands.ts +17 -0
- package/src/config.ts +27 -0
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +29 -0
- package/src/index.ts +50 -7
- package/src/lifecycle-reconcile.ts +234 -7
- package/src/orchestrator.ts +622 -79
- package/src/panel-completion.ts +65 -17
- package/src/panel-deadlines.ts +90 -0
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +85 -3
- package/src/result-extract.ts +67 -5
- package/src/run-builder.ts +129 -9
- package/src/run-observations.ts +3 -1
- package/src/run-store.ts +385 -11
- package/src/status.ts +1 -1
- package/src/subagents-rpc.ts +10 -0
- package/src/types.ts +89 -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,90 @@
|
|
|
1
|
+
import type { FusionRun, PanelDeadlineState } from "./types.js";
|
|
2
|
+
import { isRecord } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export const PANEL_FINALIZE_RESERVE_MS = 60_000;
|
|
5
|
+
export const PANEL_DECISION_WAIT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export interface PanelDeadlineAction {
|
|
8
|
+
state: PanelDeadlineState;
|
|
9
|
+
kind: "ask" | "finish";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Plan controls only for unambiguous, live children of the current panel. */
|
|
13
|
+
export function planPanelDeadlines(
|
|
14
|
+
run: FusionRun,
|
|
15
|
+
steps: readonly unknown[],
|
|
16
|
+
now: number,
|
|
17
|
+
): PanelDeadlineAction[] {
|
|
18
|
+
const timeouts = run.effectiveTimeouts;
|
|
19
|
+
if (!timeouts?.panelistSoftTimeoutMs || !run.profileSnapshot) return [];
|
|
20
|
+
const candidates = steps.flatMap((step) => {
|
|
21
|
+
if (!isRecord(step) || (step.status ?? step.state) !== "running") return [];
|
|
22
|
+
const key = step.workflowKey ?? step.key ?? step.agent;
|
|
23
|
+
const match =
|
|
24
|
+
typeof key === "string" ? key.match(/^panel-([1-9]\d*)$/) : undefined;
|
|
25
|
+
const index = match ? Number(match[1]) - 1 : undefined;
|
|
26
|
+
if (
|
|
27
|
+
index === undefined ||
|
|
28
|
+
index >= run.profileSnapshot!.panel.length ||
|
|
29
|
+
typeof step.runId !== "string" ||
|
|
30
|
+
!step.runId.trim() ||
|
|
31
|
+
typeof step.startedAt !== "number" ||
|
|
32
|
+
!Number.isFinite(step.startedAt)
|
|
33
|
+
)
|
|
34
|
+
return [];
|
|
35
|
+
return [{ index, childRunId: step.runId, startedAt: step.startedAt }];
|
|
36
|
+
});
|
|
37
|
+
const actions: PanelDeadlineAction[] = [];
|
|
38
|
+
for (const child of candidates) {
|
|
39
|
+
if (
|
|
40
|
+
candidates.filter(
|
|
41
|
+
(item) =>
|
|
42
|
+
item.index === child.index || item.childRunId === child.childRunId,
|
|
43
|
+
).length !== 1
|
|
44
|
+
)
|
|
45
|
+
continue;
|
|
46
|
+
const previous = run.panelDeadlines?.find(
|
|
47
|
+
(item) => item.index === child.index,
|
|
48
|
+
);
|
|
49
|
+
// A different run ID in the same slot is not authority to control it.
|
|
50
|
+
if (previous && previous.childRunId !== child.childRunId) continue;
|
|
51
|
+
const hardDeadlineAt = child.startedAt + timeouts.panelistTimeoutMs;
|
|
52
|
+
if (now >= hardDeadlineAt || previous?.status === "finishing") continue;
|
|
53
|
+
const finalizeAt = hardDeadlineAt - PANEL_FINALIZE_RESERVE_MS;
|
|
54
|
+
if (previous) {
|
|
55
|
+
const finishAt =
|
|
56
|
+
previous.status === "pending"
|
|
57
|
+
? Math.min(previous.requestedAt + PANEL_DECISION_WAIT_MS, finalizeAt)
|
|
58
|
+
: finalizeAt;
|
|
59
|
+
if (now >= finishAt)
|
|
60
|
+
actions.push({
|
|
61
|
+
kind: "finish",
|
|
62
|
+
state: { ...previous, status: "finishing" },
|
|
63
|
+
});
|
|
64
|
+
} else if (now >= child.startedAt + timeouts.panelistSoftTimeoutMs) {
|
|
65
|
+
const finishing = now >= finalizeAt;
|
|
66
|
+
actions.push({
|
|
67
|
+
kind: finishing ? "finish" : "ask",
|
|
68
|
+
state: {
|
|
69
|
+
index: child.index,
|
|
70
|
+
childRunId: child.childRunId,
|
|
71
|
+
requestedAt: now,
|
|
72
|
+
finalizeAt,
|
|
73
|
+
hardDeadlineAt,
|
|
74
|
+
status: finishing ? "finishing" : "pending",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return actions;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function deadlineSteerMessage(state: PanelDeadlineState): string {
|
|
83
|
+
if (state.status === "finishing") {
|
|
84
|
+
return "Fusion time budget: stop new investigation and return your best current answer now in the original output contract. State unfinished checks and uncertainty. Do not restart or wait for another decision. The hard deadline is unchanged.";
|
|
85
|
+
}
|
|
86
|
+
if (state.status === "continued") {
|
|
87
|
+
return `Fusion continuation approved within the existing budget. Finish investigation by ${new Date(state.finalizeAt).toISOString()} and return the answer. The hard deadline is unchanged; there will be no further extension.`;
|
|
88
|
+
}
|
|
89
|
+
return "Fusion soft deadline reached. At the next safe point, send a short progress_update through contact_supervisor if available: findings so far, unfinished checks, and time needed. Do not block waiting in a supervisor tool. Finish the current check while the parent decides whether to continue; do not expand scope. If no decision arrives, Fusion will ask you to finalize shortly. Keep the original final-output contract.";
|
|
90
|
+
}
|
|
@@ -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
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import type { PanelMemberConfig } from "./types.js";
|
|
11
11
|
|
|
12
12
|
export type ResultExtractErrorCode =
|
|
13
|
-
"missing-results" | "unknown-result-shape" | "missing-result-field";
|
|
13
|
+
"missing-results" | "unknown-result-shape" | "missing-result-field" | "incomplete-lifecycle";
|
|
14
14
|
|
|
15
15
|
export interface ResultExtractError {
|
|
16
16
|
code: ResultExtractErrorCode;
|
|
@@ -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,
|