@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.
@@ -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,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
- 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",
@@ -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
- 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,