@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.
@@ -2,6 +2,9 @@ import {
2
2
  callerOutputContractInstructions,
3
3
  detectCallerOutputContract,
4
4
  } from "./caller-contract.js";
5
+ import { FusionArgsError } from "./errors.js";
6
+ import { PANEL_FINALIZE_RESERVE_MS } from "./panel-deadlines.js";
7
+ import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
5
8
  import {
6
9
  PANEL_DECISION_CLOSE,
7
10
  PANEL_DECISION_OPEN,
@@ -13,6 +16,8 @@ import {
13
16
  panelItemLabel,
14
17
  resolveSynthesisMode,
15
18
  type CallerOutputContract,
19
+ type EffectiveFusionTimeouts,
20
+ type FusionTimeoutOverrides,
16
21
  THINKING_LEVELS,
17
22
  type FailedPanelSummary,
18
23
  type FusionProfile,
@@ -31,6 +36,8 @@ export const FUSION_ACCEPTANCE_DISABLED = {
31
36
  export type FusionAcceptanceDisabled = typeof FUSION_ACCEPTANCE_DISABLED;
32
37
 
33
38
  const DEFAULT_STAGE_TIMEOUT_MS = 900_000;
39
+ const DEFAULT_PANELIST_TIMEOUT_MS = 840_000;
40
+ const DEFAULT_PANEL_GRACE_MS = 5_000;
34
41
  const DEFAULT_TOOL_BUDGET: ToolBudget = {
35
42
  soft: 8,
36
43
  hard: 12,
@@ -48,6 +55,8 @@ export interface PanelSubagentTaskParams {
48
55
  model?: string;
49
56
  /** Per-task cap so a panelist finalises before the workflow timeout. */
50
57
  toolBudget: ToolBudget;
58
+ /** Child deadline, always shorter than the enclosing panel workflow. */
59
+ timeoutMs?: number;
51
60
  }
52
61
 
53
62
  export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
@@ -100,6 +109,9 @@ export interface BuildJudgeSpawnParamsInput {
100
109
  */
101
110
  runId: string;
102
111
  callerContract?: CallerOutputContract;
112
+ timeoutOverrides?: FusionTimeoutOverrides;
113
+ /** Persisted at panel start so restored fallback judges keep their deadline. */
114
+ effectiveTimeouts?: EffectiveFusionTimeouts;
103
115
  }
104
116
 
105
117
  const PANEL_OUTPUT_CONTRACT = [
@@ -168,7 +180,9 @@ export function buildPanelSpawnParams(
168
180
  profile: FusionProfile,
169
181
  prompt: string,
170
182
  callerContract?: CallerOutputContract,
183
+ timeoutOverrides?: FusionTimeoutOverrides,
171
184
  ): PanelSpawnParams {
185
+ const timeouts = resolveEffectiveTimeouts(profile, timeoutOverrides);
172
186
  const concurrency = profile.concurrency ?? profile.panel.length;
173
187
  const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
174
188
  (member, index) => ({
@@ -179,22 +193,30 @@ export function buildPanelSpawnParams(
179
193
  profile.stopWhenPanelAgrees === true,
180
194
  profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
181
195
  callerContract,
196
+ timeouts.panelistTimeoutMs,
197
+ timeouts.panelistSoftTimeoutMs !== undefined,
182
198
  ),
183
199
  }),
184
200
  );
185
201
 
202
+ const requiredSuccessfulPanelists = resolveMinimumSuccessfulPanelists(
203
+ profile.minimumSuccessfulPanelists,
204
+ profile.panel.length,
205
+ );
206
+
186
207
  return {
187
208
  workflowScript: buildPanelWorkflowScript(
188
209
  tasks,
189
210
  concurrency,
190
211
  profile.stopWhenPanelAgrees === true,
212
+ requiredSuccessfulPanelists,
191
213
  ),
192
214
  async: true,
193
215
  context: profile.context ?? "fresh",
194
216
  output: true,
195
217
  outputMode: "inline",
196
218
  acceptance: FUSION_ACCEPTANCE_DISABLED,
197
- timeoutMs: resolveStageTimeout(profile.panelTimeoutMs, profile.timeoutMs),
219
+ timeoutMs: timeouts.panelTimeoutMs,
198
220
  };
199
221
  }
200
222
 
@@ -223,10 +245,9 @@ export function buildJudgeSpawnParams(
223
245
  output: true,
224
246
  outputMode: "inline",
225
247
  acceptance: FUSION_ACCEPTANCE_DISABLED,
226
- timeoutMs: resolveStageTimeout(
227
- input.profile.judgeTimeoutMs,
228
- input.profile.timeoutMs,
229
- ),
248
+ timeoutMs:
249
+ input.effectiveTimeouts?.judgeTimeoutMs ??
250
+ resolveEffectiveTimeouts(input.profile, input.timeoutOverrides).judgeTimeoutMs,
230
251
  };
231
252
  }
232
253
 
@@ -234,13 +255,41 @@ function buildPanelWorkflowScript(
234
255
  tasks: readonly PanelWorkflowTaskParams[],
235
256
  concurrency: number,
236
257
  stopWhenAgrees: boolean,
258
+ requiredSuccessfulPanelists: number,
237
259
  ): string {
238
260
  const serializedTasks = JSON.stringify(tasks);
261
+ if (!stopWhenAgrees) {
262
+ return [
263
+ `const tasks = ${serializedTasks};`,
264
+ `const concurrency = ${concurrency};`,
265
+ "const results = new Array(tasks.length);",
266
+ "const pending = new Map();",
267
+ "let next = 0;",
268
+ "while (next < tasks.length || pending.size > 0) {",
269
+ " while (next < tasks.length && pending.size < concurrency) {",
270
+ " const index = next++;",
271
+ " const { key, ...task } = tasks[index];",
272
+ " const pendingRun = runs.run(key, task);",
273
+ " pending.set(index, Promise.all([pendingRun]).then(([result]) => ({ index, result })));",
274
+ " }",
275
+ " const { index, result } = await Promise.race(pending.values());",
276
+ " results[index] = result;",
277
+ " pending.delete(index);",
278
+ "}",
279
+ "return results;",
280
+ ].join("\n");
281
+ }
282
+ // Agreement panels deliberately use quorum-sized rounds: starting speculative
283
+ // replacements would spend more calls before the current votes can agree.
284
+ // Start no more work than the resolved quorum requires. This preserves the
285
+ // two-at-a-time majority behavior while allowing a larger configured quorum
286
+ // to be observed before agreement can stop the remaining panelists.
239
287
  const effectiveConcurrency = stopWhenAgrees
240
- ? Math.min(concurrency, 2)
288
+ ? Math.min(concurrency, requiredSuccessfulPanelists)
241
289
  : concurrency;
242
290
  const stopLogic = stopWhenAgrees
243
291
  ? [
292
+ `const requiredSuccessfulPanelists = ${requiredSuccessfulPanelists};`,
244
293
  "const decisions = results",
245
294
  " .filter((result) => result && result.ok === true)",
246
295
  " .map((result) => {",
@@ -250,7 +299,7 @@ function buildPanelWorkflowScript(
250
299
  " try { return JSON.parse(match[1]); } catch { return undefined; }",
251
300
  " })",
252
301
  " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
253
- "if (decisions.length >= 2 && results.length < tasks.length) {",
302
+ "if (decisions.length >= requiredSuccessfulPanelists && results.length < tasks.length) {",
254
303
  " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
255
304
  " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
256
305
  " if (agrees) {",
@@ -278,8 +327,72 @@ function buildPanelWorkflowScript(
278
327
  function resolveStageTimeout(
279
328
  stageTimeoutMs: number | undefined,
280
329
  legacyTimeoutMs: number | undefined,
330
+ defaultTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS,
281
331
  ): number {
282
- return stageTimeoutMs ?? legacyTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS;
332
+ return stageTimeoutMs ?? legacyTimeoutMs ?? defaultTimeoutMs;
333
+ }
334
+
335
+ /** Resolves and records the deadline precedence used for one start attempt. */
336
+ export function resolveEffectiveTimeouts(
337
+ profile: FusionProfile,
338
+ overrides: FusionTimeoutOverrides | undefined = undefined,
339
+ ): EffectiveFusionTimeouts {
340
+ const concurrency = profile.concurrency ?? profile.panel.length;
341
+ const waves = Math.ceil(profile.panel.length / concurrency);
342
+ const panelTimeoutMs = resolveStageTimeout(
343
+ overrides?.panelTimeoutMs ?? profile.panelTimeoutMs,
344
+ profile.timeoutMs,
345
+ DEFAULT_STAGE_TIMEOUT_MS * waves,
346
+ );
347
+ const panelGraceMs = resolveStageTimeout(
348
+ overrides?.panelGraceMs ?? profile.panelGraceMs,
349
+ undefined,
350
+ DEFAULT_PANEL_GRACE_MS,
351
+ );
352
+ const requestedPanelistTimeoutMs = resolveStageTimeout(
353
+ overrides?.panelistTimeoutMs ?? profile.panelistTimeoutMs,
354
+ profile.timeoutMs,
355
+ DEFAULT_PANELIST_TIMEOUT_MS,
356
+ );
357
+ if (panelGraceMs >= panelTimeoutMs) {
358
+ throw new FusionArgsError(
359
+ `panelGraceMs (${panelGraceMs}ms) must be shorter than panelTimeoutMs (${panelTimeoutMs}ms).`,
360
+ );
361
+ }
362
+ // A child must conclude before the enclosing workflow. The validated grace
363
+ // interval guarantees this cap remains a meaningful deadline.
364
+ const panelistTimeoutMs = Math.min(
365
+ requestedPanelistTimeoutMs,
366
+ panelTimeoutMs - panelGraceMs,
367
+ );
368
+ const softTimeoutMs = profile.panelistSoftTimeoutMs;
369
+ if (softTimeoutMs !== undefined && (!Number.isInteger(softTimeoutMs) || softTimeoutMs <= 0)) {
370
+ throw new FusionArgsError("panelistSoftTimeoutMs must be a positive integer.");
371
+ }
372
+ if (softTimeoutMs !== undefined && panelTimeoutMs < waves * panelistTimeoutMs + panelGraceMs) {
373
+ throw new FusionArgsError("The panel deadline must cover every concurrency wave plus grace when soft deadlines are enabled.");
374
+ }
375
+ if (softTimeoutMs !== undefined && softTimeoutMs >= panelistTimeoutMs - PANEL_FINALIZE_RESERVE_MS) {
376
+ throw new FusionArgsError("panelistSoftTimeoutMs must leave more than one minute before the effective panelist hard deadline.");
377
+ }
378
+ return {
379
+ ...(softTimeoutMs !== undefined ? { panelistSoftTimeoutMs: softTimeoutMs } : {}),
380
+ panelistTimeoutMs,
381
+ panelTimeoutMs,
382
+ panelGraceMs,
383
+ judgeTimeoutMs: resolveStageTimeout(
384
+ overrides?.judgeTimeoutMs ?? profile.judgeTimeoutMs,
385
+ profile.timeoutMs,
386
+ ),
387
+ usesLegacyTimeout:
388
+ profile.timeoutMs !== undefined &&
389
+ (overrides?.panelistTimeoutMs === undefined &&
390
+ profile.panelistTimeoutMs === undefined ||
391
+ overrides?.panelTimeoutMs === undefined &&
392
+ profile.panelTimeoutMs === undefined ||
393
+ overrides?.judgeTimeoutMs === undefined &&
394
+ profile.judgeTimeoutMs === undefined),
395
+ };
283
396
  }
284
397
 
285
398
  function buildPanelTaskParams(
@@ -288,6 +401,8 @@ function buildPanelTaskParams(
288
401
  includeDecisionRecord: boolean,
289
402
  toolBudget: ToolBudget,
290
403
  callerContract?: CallerOutputContract,
404
+ timeoutMs?: number,
405
+ allowSupervisor = false,
291
406
  ): PanelSubagentTaskParams {
292
407
  const model = appendThinkingSuffix(member.model, member.thinking);
293
408
  return {
@@ -297,6 +412,7 @@ function buildPanelTaskParams(
297
412
  prompt,
298
413
  includeDecisionRecord,
299
414
  callerContract,
415
+ allowSupervisor,
300
416
  ),
301
417
  output: true,
302
418
  outputMode: "inline",
@@ -304,6 +420,7 @@ function buildPanelTaskParams(
304
420
  skill: false,
305
421
  acceptance: FUSION_ACCEPTANCE_DISABLED,
306
422
  toolBudget,
423
+ ...(timeoutMs ? { timeoutMs } : {}),
307
424
  ...(model ? { model } : {}),
308
425
  };
309
426
  }
@@ -313,6 +430,7 @@ function buildPanelTask(
313
430
  prompt: string,
314
431
  includeDecisionRecord: boolean,
315
432
  callerContractOverride?: CallerOutputContract,
433
+ allowSupervisor = false,
316
434
  ): string {
317
435
  const role = member.role?.trim() || "independent analysis and critique";
318
436
  const callerContract =
@@ -326,7 +444,9 @@ function buildPanelTask(
326
444
  "Instructions:",
327
445
  "- Work independently from the other panelists.",
328
446
  "- Read-only: inspect only; leave files, git state, and the workspace untouched.",
329
- "- Do not ask other agents.",
447
+ allowSupervisor
448
+ ? "- Do not consult other panelists. Parent supervisor coordination is allowed only for progress updates and deadline decisions."
449
+ : "- Do not ask other agents.",
330
450
  "- Do not run subagents.",
331
451
  "- Use local inspection only when code evidence is needed.",
332
452
  "- Be concise and cite evidence when you inspect files.",
@@ -151,7 +151,9 @@ export function extractRunObservation(value: unknown): RunObservation {
151
151
  const providerFailures = summarizeProviderFailures(
152
152
  rawError &&
153
153
  attemptFailures.length === 0 &&
154
- (value.success === false || value.state === "failed")
154
+ (value.success === false ||
155
+ value.state === "failed" ||
156
+ value.status === "failed")
155
157
  ? [
156
158
  {
157
159
  provider: model ? providerFromModel(model) : "unknown provider",