@alexeiled/pi-fusion 0.8.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 CHANGED
@@ -140,6 +140,8 @@ Do not use it for trivial edits, formatting, or obvious one-step fixes.
140
140
  /fusion -p <name> <prompt>
141
141
  /fusion status
142
142
  /fusion stop
143
+ /fusion continue <fusion-run-id> <panelist-number>
144
+ /fusion finish <fusion-run-id> <panelist-number>
143
145
  /fusion init
144
146
  ```
145
147
 
@@ -208,7 +210,9 @@ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./
208
210
  - Project config lives at `.pi/fusion.json`. Global config lives at `~/.pi/agent/fusion.json`.
209
211
  - Output appears as a Pi custom message. Active progress also uses the `fusion` status key.
210
212
  - Active runs are reconciled from `pi-subagents` lifecycle artifacts, not only completion events. Verified panel outputs survive a panel deadline; unavailable perspectives and timeout failures are disclosed in a partial report when quorum is not met or coverage is incomplete.
211
- - Timeouts end the current child or workflow attempt. Fusion never automatically retries panelists, restarts a panel, or extends a deadline; start a new run manually after the terminal report.
213
+ - Normal panels refill free concurrency slots immediately. Agreement-stopping panels retain quorum-sized rounds. Default panel time covers the configured concurrency waves; explicit deadlines remain hard caps.
214
+ - Optional `panelistSoftTimeoutMs` asks the parent for a deadline decision. The parent can approve one continuation within the existing hard budget or ask for current findings. No reply within one minute requests finalization. Fusion reserves one minute to finalize, never revives children or extends hard deadlines, and cannot guarantee a final answer from an unresponsive provider.
215
+ - Incomplete terminal snapshots get a bounded five-second reconciliation window. Late errors are retained; at a confirmed workflow deadline, genuinely absent slots become explicit failures instead of discarding successful answers.
212
216
  - `pi-fusion` does not own the footer.
213
217
  - Fusion sends your prompt and any inspected snippets to every panel model, and to the judge, through `pi-subagents`.
214
218
  - Reports include available per-panel and judge time, aggregate model time, usage, estimated cost, and model failure details. Missing provider usage is shown as unknown. `$0.0000` is a known zero cost.
@@ -30,7 +30,9 @@ same working directory at the same time.
30
30
  - `web_research` is slow and expensive. Use `web_search` or `web_answer` unless
31
31
  the task truly needs a deep report.
32
32
 
33
- Work independently. Do not ask other agents. Do not run subagents.
33
+ Work independently. Do not consult other panelists. Do not run subagents.
34
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
35
+ When asked to finalize, return verified findings and name unfinished checks.
34
36
 
35
37
  Return concise Markdown with these sections.
36
38
 
@@ -18,7 +18,9 @@ Requires `pi-web-providers`. Without it every task using this agent fails with
18
18
  Work independently. Inspect relevant local files when the task needs code evidence.
19
19
  Search the web when the task turns on external facts: library behaviour, version
20
20
  differences, standards, or anything you would otherwise have to guess. Cite what
21
- you retrieved. Do not edit files. Do not ask other agents. Do not run subagents.
21
+ you retrieved. Do not edit files. Do not consult other panelists. Do not run subagents.
22
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
23
+ When asked to finalize, return verified findings and name unfinished checks.
22
24
 
23
25
  Return concise Markdown with these sections.
24
26
 
@@ -15,7 +15,9 @@ You are a pi-fusion panelist.
15
15
  Work independently. Inspect relevant local files when the task needs code evidence.
16
16
  You have no web access: answer from the repository and your own knowledge, and say
17
17
  so plainly when a question turns on external facts you cannot retrieve.
18
- Do not edit files. Do not ask other agents. Do not run subagents.
18
+ Do not edit files. Do not consult other panelists. Do not run subagents.
19
+ Parent supervisor coordination is allowed for progress updates and deadline decisions.
20
+ When asked to finalize, return verified findings and name unfinished checks.
19
21
 
20
22
  If the task defines an exact caller output contract, follow it instead of the
21
23
  sections and decision record below. Return only the caller's required syntax.
@@ -45,6 +45,8 @@ Preferred command shape:
45
45
  /fusion --panel <entries> <prompt>
46
46
  /fusion status
47
47
  /fusion stop
48
+ /fusion continue <fusion-run-id> <panelist-number>
49
+ /fusion finish <fusion-run-id> <panelist-number>
48
50
  /fusion init
49
51
  ```
50
52
 
@@ -152,10 +154,11 @@ Profile:
152
154
 
153
155
  - `panel`: one or more panel members
154
156
  - `judge`: judge agent config
155
- - `concurrency`: max parallel panelists. When `stopWhenPanelAgrees` is on, Fusion initially executes only the resolved synthesis quorum (capped by `concurrency`) before launching another batch, so a non-default quorum—not always the first two—governs early agreement.
157
+ - `concurrency`: max parallel panelists. Ordinary panels immediately start the next queued member when a slot finishes or fails, while retaining configured result order. When `stopWhenPanelAgrees` is on, Fusion initially executes only the resolved synthesis quorum (capped by `concurrency`) before launching another batch, so a non-default quorum—not always the first two—governs early agreement.
156
158
  - `timeoutMs`: legacy shared wall-clock timeout in milliseconds. It is a fallback only; `/fusion status` warns when it supplied an effective deadline.
159
+ - `panelistSoftTimeoutMs`: optional soft deadline, measured from each child's actual start, not its queue time. Requires `pi-subagents` RPC advertising `nonRecoveringSteer`. It must leave more than one minute before the effective hard child deadline, and the panel budget must cover every concurrency wave plus grace.
157
160
  - `panelistTimeoutMs`: per-panelist deadline. Fusion caps it below the enclosing panel deadline.
158
- - `panelTimeoutMs`: panel workflow wall-clock deadline.
161
+ - `panelTimeoutMs`: panel workflow wall-clock deadline. When neither it nor legacy `timeoutMs` is set, the default is 15 minutes multiplied by `ceil(panel size / concurrency)`. An explicit short deadline can still cut queued work off; soft-deadline profiles reject insufficient wave budgets.
159
162
  - `panelGraceMs`: reserved time between a child deadline and the enclosing panel deadline (default 5 seconds).
160
163
  - `judgeTimeoutMs`: synthesis workflow deadline. For every deadline the precedence is per-run CLI/tool/RPC override, stage profile field, legacy `timeoutMs`, then the built-in default.
161
164
  - `minimumSuccessfulPanelists`: `"majority"` (default), `"all"`, or a positive number. It is the panel quorum for synthesis. For a multi-member panel, numeric `1` is effectively `2`: synthesis needs two candidate answers. A one-member panel remains a direct single-panel result.
@@ -168,6 +171,44 @@ Profile:
168
171
 
169
172
  Timeouts are hard workflow deadlines. A child terminated at the deadline can report exit 143. Fusion durably keeps verified completed slots, turns terminal running/interrupted slots into typed failures, and fails closed when lifecycle sources genuinely disagree. A timed-out judge never becomes a panel-only success. When at least one valid panel result exists, Fusion produces either synthesis at quorum or an explicitly unsynthesized partial report below quorum; failures and timeouts are disclosed as missing coverage. Only zero successful outputs fail outright. Fusion never automatically retries a failed panelist, restarts a panel, or extends a deadline.
170
173
 
174
+ ### Soft deadline decisions
175
+
176
+ For a six-member panel with concurrency four, a bounded review budget can be:
177
+
178
+ ```json
179
+ {
180
+ "panelistSoftTimeoutMs": 600000,
181
+ "panelistTimeoutMs": 960000,
182
+ "panelTimeoutMs": 2100000,
183
+ "panelGraceMs": 30000,
184
+ "judgeTimeoutMs": 600000
185
+ }
186
+ ```
187
+
188
+ After ten minutes of actual child runtime, Fusion asks the parent agent to decide
189
+ and requests a progress update from that panelist. The parent may ask the user,
190
+ then call `resolve_fusion_deadline` with the Fusion `runId`, one-based `panelist`
191
+ number, and `decision: "continue"` or `"finish"`. Users can use the corresponding
192
+ `/fusion continue` or `/fusion finish` command. The judge is not involved.
193
+
194
+ A decision is accepted only for the same live child in a pending request. No
195
+ reply within one minute requests finalization. A continuation uses the already
196
+ reserved budget: in this example, investigation ends at minute 15 and the hard
197
+ stop remains minute 16. There is no second extension. Decisions survive reload;
198
+ expired requests and completed/replaced children cannot receive a new grant.
199
+
200
+ Guidance is sent through non-recovering `steer` RPC. Receipts are not proof of
201
+ model compliance. Missing routes are visible in `/fusion status`; Fusion does
202
+ not revive a child, reset a timer, or retry inference. A blocked provider or tool
203
+ may not produce a final answer before the hard stop. Verified completed answers
204
+ remain available for partial reporting.
205
+
206
+ At a terminal workflow deadline, incomplete lifecycle snapshots get up to five
207
+ seconds to settle. A late child error keeps its actual cause. After that window,
208
+ slots still absent with unambiguous identities become deadline failures. Duplicate
209
+ or conflicting identities still fail closed. Incomplete *successful* workflows
210
+ are not silently repaired into successes.
211
+
171
212
  Panel member:
172
213
 
173
214
  - `id`: stable machine name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/commands.ts CHANGED
@@ -19,6 +19,8 @@ const FUSION_HELP = [
19
19
  "/fusion --panel <models> <prompt>",
20
20
  "/fusion status",
21
21
  "/fusion stop",
22
+ "/fusion continue <fusion-run-id> <panelist-number>",
23
+ "/fusion finish <fusion-run-id> <panelist-number>",
22
24
  "/fusion init",
23
25
  ].join("\n");
24
26
 
@@ -29,6 +31,7 @@ export interface FusionRuntimeCommandHandler {
29
31
  ): Promise<unknown>;
30
32
  showStatus(ctx: ExtensionCommandContext): Promise<unknown>;
31
33
  cancelActiveRun(ctx: ExtensionCommandContext): Promise<unknown>;
34
+ resolvePanelDeadline?(runId: string, panelist: number, decision: "continue" | "finish"): Promise<unknown>;
32
35
  }
33
36
 
34
37
  export function registerFusionCommands(
@@ -43,6 +46,20 @@ export function registerFusionCommands(
43
46
  return;
44
47
  }
45
48
 
49
+ const decision = args.trim().match(/^(continue|finish)\s+(\S+)\s+([1-9]\d*)$/);
50
+ if (decision && handler.resolvePanelDeadline) {
51
+ try {
52
+ await handler.resolvePanelDeadline(decision[2]!, Number(decision[3]), decision[1] === "continue" ? "continue" : "finish");
53
+ ctx.ui.notify("Deadline decision recorded; guidance requested. Hard deadline unchanged.", "info");
54
+ } catch (error: unknown) {
55
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
56
+ }
57
+ return;
58
+ }
59
+ if (/^(continue|finish)(?:\s|$)/.test(args.trim())) {
60
+ ctx.ui.notify("Use /fusion continue|finish <fusion-run-id> <panelist-number>.", "error");
61
+ return;
62
+ }
46
63
  const inlineCommand = parseFusionInlineCommand(args);
47
64
  if (inlineCommand === "init") {
48
65
  await runFusionInit(ctx);
package/src/config.ts CHANGED
@@ -325,6 +325,7 @@ function isFusionProfile(value: unknown): value is FusionProfile {
325
325
  return false;
326
326
  if (value.timeoutMs !== undefined && !isPositiveInteger(value.timeoutMs))
327
327
  return false;
328
+ if (value.panelistSoftTimeoutMs !== undefined && !isPositiveInteger(value.panelistSoftTimeoutMs)) return false;
328
329
  if (
329
330
  value.panelistTimeoutMs !== undefined &&
330
331
  !isPositiveInteger(value.panelistTimeoutMs)
package/src/index.ts CHANGED
@@ -69,7 +69,7 @@ function registerFusionTool(
69
69
  );
70
70
  const text =
71
71
  result.status === "started"
72
- ? "Fusion panel review started. The report will be posted when the panel and judge finish."
72
+ ? "Fusion panel review started. The report will be posted when the panel finishes; synthesis may be skipped below quorum."
73
73
  : result.status === "conflict"
74
74
  ? `A fusion run is already active (${result.activeRunId}). Do not start another; wait for its report.`
75
75
  : `Fusion review failed to start: ${result.status === "failed" ? result.error : result.status}`;
@@ -98,11 +98,26 @@ export default function fusionExtension(pi: ExtensionAPI): void {
98
98
  const orchestrator = new FusionOrchestrator({
99
99
  rpc: new SubagentsRpcClient({ events: pi.events }),
100
100
  runStore: store,
101
- sendMessage: (message) => pi.sendMessage(message),
101
+ sendMessage: (message, options) => pi.sendMessage(message, options),
102
102
  });
103
103
 
104
104
  registerFusionCommands(pi, orchestrator);
105
105
  registerFusionTool(pi, orchestrator);
106
+ pi.registerTool({
107
+ name: "resolve_fusion_deadline",
108
+ label: "Fusion Deadline Decision",
109
+ description: "Answer a pending Fusion soft-deadline request. Continue once within the existing hard budget, or ask the panelist to finish with current findings. Does not restart runs or extend hard deadlines. Delivery receipt is not proof the model complied.",
110
+ parameters: Type.Object({
111
+ runId: Type.String({ minLength: 1 }),
112
+ panelist: Type.Integer({ minimum: 1, description: "One-based panelist number from the deadline notice" }),
113
+ decision: Type.String({ enum: ["continue", "finish"] }),
114
+ }),
115
+ async execute(_id, params) {
116
+ if (params.decision !== "continue" && params.decision !== "finish") throw new Error("Expected continue or finish.");
117
+ const details = await orchestrator.resolvePanelDeadline(params.runId, params.panelist, params.decision);
118
+ return { content: [{ type: "text", text: "Decision recorded and guidance requested. The hard deadline is unchanged; the receipt does not prove model compliance." }], details };
119
+ },
120
+ });
106
121
 
107
122
  const unsubscribeComplete = pi.events.on(
108
123
  SUBAGENT_ASYNC_COMPLETE_EVENT,
@@ -43,6 +43,8 @@ export interface ReconcilePanelResultsOptions {
43
43
  stoppedPanelIndices?: readonly number[];
44
44
  /** A workflow deadline terminalized running child slots as timeout failures. */
45
45
  terminalizeRunning?: boolean;
46
+ /** Only after the terminal-snapshot grace period, and only at a deadline. */
47
+ allowMissingAtDeadline?: boolean;
46
48
  }
47
49
 
48
50
  /**
@@ -82,6 +84,7 @@ export function reconcilePanelResults(
82
84
  return error(
83
85
  `Terminal subagents data described ${eventResults.outputs.length + eventResults.failures.length} of ${expectedCount} configured panel members.`,
84
86
  "$.results",
87
+ "incomplete-lifecycle",
85
88
  );
86
89
  }
87
90
  return eventResults;
@@ -100,6 +103,7 @@ export function reconcilePanelResults(
100
103
  ? { terminalizeRunning: true }
101
104
  : { completedOnly: true }),
102
105
  limit: expectedCount,
106
+ ...(options.allowMissingAtDeadline ? { requireStableSlotIdentity: true } : {}),
103
107
  ...(options.stoppedPanelIndices
104
108
  ? { stoppedPanelIndices: options.stoppedPanelIndices }
105
109
  : {}),
@@ -160,9 +164,34 @@ export function reconcilePanelResults(
160
164
  missingStatusIndices.length > 0 &&
161
165
  missingStatusIndices.every((index) => stopped.has(index));
162
166
  if (!missingWereStopped) {
167
+ if (options.terminalizeRunning && options.allowMissingAtDeadline) {
168
+ const reconciled = mergeTerminalDeadlineResults(
169
+ eventResults, statusResults, statusPayload, resultPayload,
170
+ );
171
+ for (const index of missingStatusIndices) {
172
+ const output = eventResults.outputs.find((item) => item.index === index);
173
+ const failure = eventResults.failures.find((item) => item.index === index);
174
+ if (output) reconciled.outputs.push(output);
175
+ else if (failure) reconciled.failures.push(failure);
176
+ else {
177
+ const member = profile.panel[index]!;
178
+ reconciled.failures.push({
179
+ index, agent: member.agent, id: member.id,
180
+ ...(member.label ? { label: member.label } : {}),
181
+ ...(member.model ? { configuredModel: member.model } : {}),
182
+ summary: "No terminal result was reported before the workflow deadline.",
183
+ reason: "timeout",
184
+ });
185
+ }
186
+ }
187
+ reconciled.outputs.sort((a, b) => a.index - b.index);
188
+ reconciled.failures.sort((a, b) => a.index - b.index);
189
+ return reconciled;
190
+ }
163
191
  return error(
164
192
  `Terminal subagents status described ${statusCount} of ${expectedCount} configured panel members.`,
165
193
  "$.steps",
194
+ statusCount === 0 && !options.terminalizeRunning ? "unknown-result-shape" : "incomplete-lifecycle",
166
195
  );
167
196
  }
168
197
 
@@ -433,11 +462,12 @@ function unknownArray(value: unknown): readonly unknown[] | undefined {
433
462
  function error(
434
463
  message: string,
435
464
  path: string,
465
+ code: "unknown-result-shape" | "incomplete-lifecycle" = "unknown-result-shape",
436
466
  ): ExtractPanelResultsResult {
437
467
  return {
438
468
  ok: false,
439
469
  error: {
440
- code: "unknown-result-shape",
470
+ code,
441
471
  message,
442
472
  path,
443
473
  },
@@ -1,3 +1,4 @@
1
+ import { deadlineSteerMessage, planPanelDeadlines, PANEL_DECISION_WAIT_MS } from "./panel-deadlines.js";
1
2
  import { applyClaudeAliasShorthand } from "./claude-aliases.js";
2
3
  import {
3
4
  detectCallerOutputContract,
@@ -23,10 +24,12 @@ import {
23
24
  import {
24
25
  extractPanelResults,
25
26
  type ExtractPanelResultsSuccess,
27
+ type ExtractPanelResultsResult,
26
28
  } from "./result-extract.js";
27
29
  import {
28
30
  reconcileIndexedLifecycleResult,
29
31
  reconcilePanelResults,
32
+ type ReconcilePanelResultsOptions,
30
33
  } from "./lifecycle-reconcile.js";
31
34
  import {
32
35
  appendThinkingSuffix,
@@ -59,6 +62,7 @@ import {
59
62
  type FusionProfileSnapshot,
60
63
  type FusionRun,
61
64
  type PanelOutput,
65
+ type PanelDeadlineState,
62
66
  type ParsedFusionArgs,
63
67
  } from "./types.js";
64
68
  import {
@@ -66,7 +70,7 @@ import {
66
70
  hasStrongPanelAgreement,
67
71
  mergeRunObservations,
68
72
  } from "./run-observations.js";
69
- import type { SubagentsTargetParams } from "./subagents-rpc.js";
73
+ import type { SubagentsTargetParams, SubagentsSteerParams } from "./subagents-rpc.js";
70
74
 
71
75
  export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete";
72
76
 
@@ -95,6 +99,7 @@ export interface FusionRpcClientLike {
95
99
  status(params?: SubagentsTargetParams): Promise<unknown>;
96
100
  stop(params: SubagentsTargetParams): Promise<unknown>;
97
101
  interrupt(params: SubagentsTargetParams): Promise<unknown>;
102
+ steer?(params: SubagentsSteerParams): Promise<unknown>;
98
103
  }
99
104
 
100
105
  export interface FusionMessageSink {
@@ -103,7 +108,7 @@ export interface FusionMessageSink {
103
108
  content: string;
104
109
  display: boolean;
105
110
  details?: unknown;
106
- }): void;
111
+ }, options?: { triggerTurn: boolean; deliverAs: "steer" }): void;
107
112
  }
108
113
 
109
114
  export interface FusionOrchestratorDeps {
@@ -144,6 +149,7 @@ export class FusionOrchestrator {
144
149
  private reconcileTimer: NodeJS.Timeout | undefined;
145
150
  private reconciling = false;
146
151
  private pendingCompletionPayload: unknown;
152
+ private readonly incompleteTerminalSince = new Map<string, number>();
147
153
 
148
154
  constructor(deps: FusionOrchestratorDeps) {
149
155
  this.rpc = deps.rpc;
@@ -172,8 +178,9 @@ export class FusionOrchestrator {
172
178
  return { status: "conflict", activeRunId: existing.id };
173
179
  }
174
180
 
181
+ let subagentsInfo: unknown;
175
182
  try {
176
- await this.rpc.ping();
183
+ subagentsInfo = await this.rpc.ping();
177
184
  this.installWarning = undefined;
178
185
  } catch (error: unknown) {
179
186
  const message = `pi-subagents RPC is unavailable: ${errorMessage(error)}`;
@@ -207,6 +214,11 @@ export class FusionOrchestrator {
207
214
  );
208
215
  resolved = this.resolveProfile(aliased, inlineName);
209
216
  }
217
+ if (resolved.profile.panelistSoftTimeoutMs !== undefined &&
218
+ (!this.rpc.steer || !isRecord(subagentsInfo) || !isRecord(subagentsInfo.capabilities) ||
219
+ subagentsInfo.capabilities.nonRecoveringSteer !== true)) {
220
+ throw new FusionArgsError("Soft deadlines require pi-subagents RPC with nonRecoveringSteer. Update pi-subagents and reload Pi, or omit panelistSoftTimeoutMs.");
221
+ }
210
222
  this.configWarning = undefined;
211
223
  } catch (error: unknown) {
212
224
  const message = errorMessage(error);
@@ -608,6 +620,109 @@ export class FusionOrchestrator {
608
620
  return this.runStore.getActiveRun();
609
621
  }
610
622
 
623
+ async resolvePanelDeadline(
624
+ runId: string,
625
+ panelist: number,
626
+ decision: "continue" | "finish",
627
+ ): Promise<{ decision: string; receipt: unknown }> {
628
+ const active = this.runStore.getActiveRun();
629
+ if (!active || active.id !== runId || active.phase !== "panel" || !active.panelRunId) {
630
+ throw new FusionArgsError("The requested Fusion panel is no longer active.");
631
+ }
632
+ if (!Number.isInteger(panelist) || panelist < 1 || (decision !== "continue" && decision !== "finish")) {
633
+ throw new FusionArgsError("Expected a one-based panelist number and continue or finish.");
634
+ }
635
+ const state = active.panelDeadlines?.find((item) => item.index === panelist - 1);
636
+ if (!state || state.status !== "pending") throw new FusionArgsError("No pending deadline decision for that panelist.");
637
+ const payload = await this.rpc.status({ id: active.panelRunId });
638
+ const matches = findStepsArray(payload).filter((step) => isRecord(step) &&
639
+ step.runId === state.childRunId && (step.status ?? step.state) === "running" &&
640
+ (step.workflowKey ?? step.key ?? step.agent) === `panel-${panelist}`);
641
+ const latest = this.runStore.getActiveRun();
642
+ if (latest?.id !== runId || latest.phase !== "panel" ||
643
+ latest.panelDeadlines?.find((item) => item.index === state.index)?.status !== "pending" ||
644
+ isTerminalSubagentState(extractSubagentState(payload)) || matches.length !== 1 ||
645
+ Date.now() >= Math.min(state.requestedAt + PANEL_DECISION_WAIT_MS, state.finalizeAt)) {
646
+ throw new FusionArgsError("Deadline decision expired or the panelist is no longer running.");
647
+ }
648
+ const updated: PanelDeadlineState = { ...state, status: decision === "continue" ? "continued" : "finishing" };
649
+ this.savePanelDeadline(runId, updated);
650
+ const receipt = await this.steerPanelDeadline(runId, updated);
651
+ return { decision, receipt };
652
+ }
653
+
654
+ private savePanelDeadline(runId: string, state: PanelDeadlineState): void {
655
+ const active = this.runStore.getActiveRun();
656
+ if (active?.id !== runId || active.phase !== "panel") return;
657
+ this.runStore.updateRun(runId, {
658
+ panelDeadlines: [...(active.panelDeadlines ?? []).filter((item) => item.index !== state.index), state],
659
+ });
660
+ }
661
+
662
+ private async steerPanelDeadline(runId: string, state: PanelDeadlineState): Promise<unknown> {
663
+ try {
664
+ if (!this.rpc.steer) throw new Error("pi-subagents steer RPC is unavailable.");
665
+ return await this.rpc.steer({ id: state.childRunId, message: deadlineSteerMessage(state), mode: "auto" });
666
+ } catch (error: unknown) {
667
+ const message = `Could not send deadline guidance to panel-${state.index + 1}: ${errorMessage(error)}`;
668
+ const latest = this.runStore.getActiveRun();
669
+ if (latest?.id === runId && latest.panelDeadlines?.find((item) => item.index === state.index)?.status === state.status) {
670
+ this.savePanelDeadline(runId, { ...state, deliveryError: message });
671
+ }
672
+ this.notify(this.context, message, "warning");
673
+ throw new Error(message, { cause: error });
674
+ }
675
+ }
676
+
677
+ private async handlePanelDeadlines(active: FusionRun, statusPayload: unknown): Promise<void> {
678
+ const actions = planPanelDeadlines(this.runStore.getActiveRun() ?? active, findStepsArray(statusPayload), Date.now());
679
+ for (const action of actions) {
680
+ if (this.runStore.getActiveRun()?.id !== active.id) return;
681
+ this.savePanelDeadline(active.id, action.state);
682
+ if (action.kind === "ask") {
683
+ const panelist = action.state.index + 1;
684
+ const member = active.profileSnapshot?.panel[action.state.index];
685
+ const step = findStepsArray(statusPayload).find((item) => isRecord(item) && item.runId === action.state.childRunId);
686
+ const content = [
687
+ `Fusion soft deadline: ${member ? memberLabel(member) : `panel-${panelist}`}.`,
688
+ `Fusion run: ${active.id}. Child run: ${action.state.childRunId}.`,
689
+ `Last observed activity (not verified findings): ${describeStepActivity(step)?.slice(0,500) ?? "unknown"}.`,
690
+ `Call resolve_fusion_deadline with runId=${active.id}, panelist=${panelist}, decision=continue or finish. Ask the user if the extra work needs their decision.`,
691
+ `Without a decision within ${PANEL_DECISION_WAIT_MS / 1000}s, the panelist will be asked to return its current answer. One continuation is allowed, only within the existing hard deadline.`,
692
+ `User command: /fusion continue ${active.id} ${panelist} or /fusion finish ${active.id} ${panelist}.`,
693
+ ].join("\n");
694
+ this.sendMessage?.({ customType: "fusion-deadline", content, display: true }, { triggerTurn: true, deliverAs: "steer" });
695
+ }
696
+ try {
697
+ await this.steerPanelDeadline(active.id, action.state);
698
+ } catch {
699
+ // The recorded delivery error is visible; never revive a child or reset its budget.
700
+ }
701
+ }
702
+ }
703
+
704
+ private reconcileTerminalPanel(
705
+ active: FusionRun,
706
+ extracted: ExtractPanelResultsSuccess,
707
+ statusPayload: unknown,
708
+ profile: FusionProfile,
709
+ lifecyclePayload: unknown,
710
+ options: ReconcilePanelResultsOptions,
711
+ ): ExtractPanelResultsResult | undefined {
712
+ const since = this.incompleteTerminalSince.get(active.id);
713
+ const graceExpired = since !== undefined && Date.now() - since >= WORKFLOW_RESULT_ARTIFACT_GRACE_MS;
714
+ const result = reconcilePanelResults(extracted, statusPayload, profile, lifecyclePayload, {
715
+ ...options,
716
+ ...(graceExpired && options.terminalizeRunning ? { allowMissingAtDeadline: true } : {}),
717
+ });
718
+ if (!result.ok && result.error.code === "incomplete-lifecycle" && !graceExpired) {
719
+ if (since === undefined) this.incompleteTerminalSince.set(active.id, Date.now());
720
+ return undefined;
721
+ }
722
+ this.incompleteTerminalSince.delete(active.id);
723
+ return result;
724
+ }
725
+
611
726
  private async reconcileActiveRun(
612
727
  eventPayload?: unknown,
613
728
  ): Promise<FusionCommandResult> {
@@ -623,13 +738,13 @@ export class FusionOrchestrator {
623
738
  this.reconciling = true;
624
739
  try {
625
740
  if (active.phase === "panel") {
626
- return this.handleLegacyPanelComplete(active, eventPayload);
741
+ return await this.handleLegacyPanelComplete(active, eventPayload);
627
742
  }
628
743
  if (active.phase === "chain") {
629
- return this.handleChainComplete(active, eventPayload);
744
+ return await this.handleChainComplete(active, eventPayload);
630
745
  }
631
746
  if (active.phase === "judge") {
632
- return this.handleJudgeComplete(active, eventPayload);
747
+ return await this.handleJudgeComplete(active, eventPayload);
633
748
  }
634
749
  return { status: "ignored" };
635
750
  } finally {
@@ -694,7 +809,8 @@ export class FusionOrchestrator {
694
809
  );
695
810
  }
696
811
 
697
- const observedPanels = reconcilePanelResults(
812
+ const observedPanels = this.reconcileTerminalPanel(
813
+ active,
698
814
  extracted,
699
815
  snapshot.statusPayload,
700
816
  profile,
@@ -707,6 +823,7 @@ export class FusionOrchestrator {
707
823
  : {}),
708
824
  },
709
825
  );
826
+ if (!observedPanels) return { status: "ignored" };
710
827
  if (!observedPanels.ok) {
711
828
  return this.failActiveRun(
712
829
  `${observedPanels.error.message} (${observedPanels.error.path})`,
@@ -827,6 +944,7 @@ export class FusionOrchestrator {
827
944
  const panelIsTerminal =
828
945
  snapshot.resultIsTerminal ||
829
946
  isTerminalSubagentState(extractSubagentState(snapshot.statusPayload));
947
+ if (!panelIsTerminal) await this.handlePanelDeadlines(active, snapshot.statusPayload);
830
948
  if (!active.panelStopReason && !panelIsTerminal) {
831
949
  if (
832
950
  partial &&
@@ -886,7 +1004,8 @@ export class FusionOrchestrator {
886
1004
  );
887
1005
  }
888
1006
 
889
- const observedPanels = reconcilePanelResults(
1007
+ const observedPanels = this.reconcileTerminalPanel(
1008
+ active,
890
1009
  extracted,
891
1010
  snapshot.statusPayload,
892
1011
  profile,
@@ -899,6 +1018,7 @@ export class FusionOrchestrator {
899
1018
  : {}),
900
1019
  },
901
1020
  );
1021
+ if (!observedPanels) return { status: "ignored" };
902
1022
  if (!observedPanels.ok) {
903
1023
  return this.failActiveRun(
904
1024
  `${observedPanels.error.message} (${observedPanels.error.path})`,
@@ -1381,6 +1501,7 @@ export class FusionOrchestrator {
1381
1501
  }
1382
1502
 
1383
1503
  private clearActiveRuntime(): void {
1504
+ this.incompleteTerminalSince.clear();
1384
1505
  this.activeProfile = undefined;
1385
1506
  this.stopReconcileLoop();
1386
1507
  }
@@ -1654,6 +1775,9 @@ function formatFusionStatusReport(input: {
1654
1775
  lines.push(`Profile: ${input.active.profileName}`);
1655
1776
  lines.push(`Phase: ${input.details?.phaseLabel ?? input.active.phase}`);
1656
1777
  appendEffectiveTimeouts(lines, input.active);
1778
+ for (const deadline of input.active.panelDeadlines ?? []) {
1779
+ lines.push(`Panel-${deadline.index + 1} deadline: ${deadline.status}${deadline.deliveryError ? ` (${deadline.deliveryError})` : ""}`);
1780
+ }
1657
1781
  if (input.active.chainRunId)
1658
1782
  lines.push(`Chain run: ${input.active.chainRunId}`);
1659
1783
  else if (input.active.panelRunId)
@@ -1706,6 +1830,9 @@ function appendEffectiveTimeouts(
1706
1830
  lines.push(
1707
1831
  `Deadlines: panelist ${timeouts.panelistTimeoutMs}ms, panel ${timeouts.panelTimeoutMs}ms (+${timeouts.panelGraceMs}ms grace), judge ${timeouts.judgeTimeoutMs}ms`,
1708
1832
  );
1833
+ if (timeouts.panelistSoftTimeoutMs !== undefined) {
1834
+ lines.push(`Soft deadline: ${timeouts.panelistSoftTimeoutMs}ms per child; decision wait ${PANEL_DECISION_WAIT_MS}ms; hard deadlines are unchanged.`);
1835
+ }
1709
1836
  if (timeouts.usesLegacyTimeout) {
1710
1837
  lines.push("Warning: legacy timeoutMs supplied one or more effective deadlines.");
1711
1838
  }
@@ -2139,7 +2266,7 @@ function withWorkflowDeadlineFailures(
2139
2266
  ...failure,
2140
2267
  summary: failure.summary.includes(deadlineError)
2141
2268
  ? failure.summary
2142
- : `${deadlineError}\n${failure.summary}`,
2269
+ : `${deadlineError} ${failure.summary}`,
2143
2270
  reason: failure.reason ?? "timeout",
2144
2271
  }));
2145
2272
  }
@@ -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
+ }
@@ -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;
@@ -3,6 +3,7 @@ import {
3
3
  detectCallerOutputContract,
4
4
  } from "./caller-contract.js";
5
5
  import { FusionArgsError } from "./errors.js";
6
+ import { PANEL_FINALIZE_RESERVE_MS } from "./panel-deadlines.js";
6
7
  import { resolveMinimumSuccessfulPanelists } from "./panel-quorum.js";
7
8
  import {
8
9
  PANEL_DECISION_CLOSE,
@@ -193,6 +194,7 @@ export function buildPanelSpawnParams(
193
194
  profile.panelToolBudget ?? DEFAULT_TOOL_BUDGET,
194
195
  callerContract,
195
196
  timeouts.panelistTimeoutMs,
197
+ timeouts.panelistSoftTimeoutMs !== undefined,
196
198
  ),
197
199
  }),
198
200
  );
@@ -256,6 +258,29 @@ function buildPanelWorkflowScript(
256
258
  requiredSuccessfulPanelists: number,
257
259
  ): string {
258
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.
259
284
  // Start no more work than the resolved quorum requires. This preserves the
260
285
  // two-at-a-time majority behavior while allowing a larger configured quorum
261
286
  // to be observed before agreement can stop the remaining panelists.
@@ -312,9 +337,12 @@ export function resolveEffectiveTimeouts(
312
337
  profile: FusionProfile,
313
338
  overrides: FusionTimeoutOverrides | undefined = undefined,
314
339
  ): EffectiveFusionTimeouts {
340
+ const concurrency = profile.concurrency ?? profile.panel.length;
341
+ const waves = Math.ceil(profile.panel.length / concurrency);
315
342
  const panelTimeoutMs = resolveStageTimeout(
316
343
  overrides?.panelTimeoutMs ?? profile.panelTimeoutMs,
317
344
  profile.timeoutMs,
345
+ DEFAULT_STAGE_TIMEOUT_MS * waves,
318
346
  );
319
347
  const panelGraceMs = resolveStageTimeout(
320
348
  overrides?.panelGraceMs ?? profile.panelGraceMs,
@@ -337,7 +365,18 @@ export function resolveEffectiveTimeouts(
337
365
  requestedPanelistTimeoutMs,
338
366
  panelTimeoutMs - panelGraceMs,
339
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
+ }
340
378
  return {
379
+ ...(softTimeoutMs !== undefined ? { panelistSoftTimeoutMs: softTimeoutMs } : {}),
341
380
  panelistTimeoutMs,
342
381
  panelTimeoutMs,
343
382
  panelGraceMs,
@@ -363,6 +402,7 @@ function buildPanelTaskParams(
363
402
  toolBudget: ToolBudget,
364
403
  callerContract?: CallerOutputContract,
365
404
  timeoutMs?: number,
405
+ allowSupervisor = false,
366
406
  ): PanelSubagentTaskParams {
367
407
  const model = appendThinkingSuffix(member.model, member.thinking);
368
408
  return {
@@ -372,6 +412,7 @@ function buildPanelTaskParams(
372
412
  prompt,
373
413
  includeDecisionRecord,
374
414
  callerContract,
415
+ allowSupervisor,
375
416
  ),
376
417
  output: true,
377
418
  outputMode: "inline",
@@ -389,6 +430,7 @@ function buildPanelTask(
389
430
  prompt: string,
390
431
  includeDecisionRecord: boolean,
391
432
  callerContractOverride?: CallerOutputContract,
433
+ allowSupervisor = false,
392
434
  ): string {
393
435
  const role = member.role?.trim() || "independent analysis and critique";
394
436
  const callerContract =
@@ -402,7 +444,9 @@ function buildPanelTask(
402
444
  "Instructions:",
403
445
  "- Work independently from the other panelists.",
404
446
  "- Read-only: inspect only; leave files, git state, and the workspace untouched.",
405
- "- 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.",
406
450
  "- Do not run subagents.",
407
451
  "- Use local inspection only when code evidence is needed.",
408
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",
package/src/run-store.ts CHANGED
@@ -66,6 +66,7 @@ export interface FusionRunPatch {
66
66
  panelAsyncDir?: string;
67
67
  panelStopReason?: FusionRun["panelStopReason"];
68
68
  panelStoppedIndices?: FusionRun["panelStoppedIndices"];
69
+ panelDeadlines?: FusionRun["panelDeadlines"];
69
70
  judgeRunId?: string;
70
71
  judgeAsyncDir?: string;
71
72
  judgeObservation?: FusionRun["judgeObservation"];
@@ -387,6 +388,7 @@ function applyPatch(
387
388
  if (patch.panelStoppedIndices !== undefined) {
388
389
  updated.panelStoppedIndices = [...patch.panelStoppedIndices];
389
390
  }
391
+ if (patch.panelDeadlines !== undefined) updated.panelDeadlines = patch.panelDeadlines.map((item) => ({ ...item }));
390
392
  if (patch.judgeRunId !== undefined) updated.judgeRunId = patch.judgeRunId;
391
393
  if (patch.judgeAsyncDir !== undefined) {
392
394
  updated.judgeAsyncDir = patch.judgeAsyncDir;
@@ -514,6 +516,9 @@ function cloneRun(run: FusionRun): FusionRun {
514
516
  ...(run.panelStoppedIndices !== undefined
515
517
  ? { panelStoppedIndices: [...run.panelStoppedIndices] }
516
518
  : {}),
519
+ ...(run.panelDeadlines !== undefined
520
+ ? { panelDeadlines: run.panelDeadlines.map((item) => ({ ...item })) }
521
+ : {}),
517
522
  ...(run.judgeRunId !== undefined ? { judgeRunId: run.judgeRunId } : {}),
518
523
  ...(run.judgeAsyncDir !== undefined
519
524
  ? { judgeAsyncDir: run.judgeAsyncDir }
@@ -669,6 +674,7 @@ function isFusionRunState(value: unknown): value is FusionRun {
669
674
  ) {
670
675
  return false;
671
676
  }
677
+ if (value.panelDeadlines !== undefined && !isPanelDeadlines(value.panelDeadlines)) return false;
672
678
  if (value.judgeRunId !== undefined && typeof value.judgeRunId !== "string") {
673
679
  return false;
674
680
  }
@@ -719,9 +725,27 @@ function isFusionRunSummary(value: unknown): value is FusionRunSummary {
719
725
  return isFusionRunState(value) && isTerminalPhase(value.phase);
720
726
  }
721
727
 
728
+ function isPanelDeadlines(value: unknown): boolean {
729
+ if (!Array.isArray(value)) return false;
730
+ const slots = new Set<number>();
731
+ const children = new Set<string>();
732
+ return value.every((item: unknown) => {
733
+ if (!isRecord(item) || !isPanelSlotIndex(item.index) || !isNonEmptyString(item.childRunId) ||
734
+ !isFiniteNumber(item.requestedAt) || !isFiniteNumber(item.finalizeAt) || !isFiniteNumber(item.hardDeadlineAt) ||
735
+ item.finalizeAt >= item.hardDeadlineAt ||
736
+ !["pending", "continued", "finishing"].includes(String(item.status)) ||
737
+ (item.deliveryError !== undefined && typeof item.deliveryError !== "string") ||
738
+ slots.has(item.index) || children.has(item.childRunId)) return false;
739
+ slots.add(item.index);
740
+ children.add(item.childRunId);
741
+ return true;
742
+ });
743
+ }
744
+
722
745
  function isTimeoutOverrides(value: unknown): boolean {
723
746
  if (!isRecord(value)) return false;
724
747
  return [
748
+ value.panelistSoftTimeoutMs,
725
749
  value.panelistTimeoutMs,
726
750
  value.panelTimeoutMs,
727
751
  value.panelGraceMs,
@@ -764,7 +788,7 @@ function isTerminalPhase(value: unknown): value is FusionTerminalPhase {
764
788
  export function validateFusionRunPanelSlots(
765
789
  run: Pick<
766
790
  FusionRun,
767
- "panelOutputs" | "panelFailures" | "panelStoppedIndices" | "recovery"
791
+ "panelOutputs" | "panelFailures" | "panelStoppedIndices" | "panelDeadlines" | "recovery"
768
792
  >,
769
793
  panelLength: number,
770
794
  ): string | undefined {
@@ -772,6 +796,7 @@ export function validateFusionRunPanelSlots(
772
796
  run.panelOutputs?.map((output) => output.index),
773
797
  run.panelFailures?.map((failure) => failure.index),
774
798
  run.panelStoppedIndices,
799
+ run.panelDeadlines?.map((item) => item.index),
775
800
  run.recovery?.failedPanelIndices,
776
801
  ];
777
802
  for (const slots of slotGroups) {
@@ -11,6 +11,7 @@ export const SUBAGENTS_RPC_METHODS = [
11
11
  "status",
12
12
  "stop",
13
13
  "interrupt",
14
+ "steer",
14
15
  ] as const;
15
16
 
16
17
  export type SubagentsRpcMethod = (typeof SUBAGENTS_RPC_METHODS)[number];
@@ -42,6 +43,11 @@ export interface SubagentsTargetParams {
42
43
  index?: number;
43
44
  }
44
45
 
46
+ export interface SubagentsSteerParams extends SubagentsTargetParams {
47
+ message: string;
48
+ mode: "auto";
49
+ }
50
+
45
51
  export type SubagentsSpawnParams = object;
46
52
 
47
53
  export interface SubagentsRpcRequestEnvelope {
@@ -145,6 +151,10 @@ export class SubagentsRpcClient {
145
151
  this.source = options.source ?? { extension: "pi-fusion" };
146
152
  }
147
153
 
154
+ steer(params: SubagentsSteerParams): Promise<unknown> {
155
+ return this.request("steer", params);
156
+ }
157
+
148
158
  request<T = unknown>(
149
159
  method: SubagentsRpcMethod,
150
160
  params?: unknown,
package/src/types.ts CHANGED
@@ -56,6 +56,7 @@ export interface FusionTimeoutOverrides {
56
56
  }
57
57
 
58
58
  export interface EffectiveFusionTimeouts {
59
+ panelistSoftTimeoutMs?: number;
59
60
  panelistTimeoutMs: number;
60
61
  panelTimeoutMs: number;
61
62
  panelGraceMs: number;
@@ -70,6 +71,8 @@ export interface FusionProfile {
70
71
  concurrency?: number;
71
72
  /** Legacy shared wall-clock timeout used when a stage timeout is absent. */
72
73
  timeoutMs?: number;
74
+ /** Opt-in parent decision before the hard child deadline; reserves one minute to finalize. */
75
+ panelistSoftTimeoutMs?: number;
73
76
  /** Per-child deadline. It is capped below the enclosing panel deadline. */
74
77
  panelistTimeoutMs?: number;
75
78
  /** Wall-clock timeout for the complete panel workflow. */
@@ -273,6 +276,16 @@ export interface FusionSpawnIntent {
273
276
  requestedAt: number;
274
277
  }
275
278
 
279
+ export interface PanelDeadlineState {
280
+ index: number;
281
+ childRunId: string;
282
+ requestedAt: number;
283
+ finalizeAt: number;
284
+ hardDeadlineAt: number;
285
+ status: "pending" | "continued" | "finishing";
286
+ deliveryError?: string;
287
+ }
288
+
276
289
  export interface FusionRun {
277
290
  id: string;
278
291
  prompt: string;
@@ -306,6 +319,7 @@ export interface FusionRun {
306
319
  panelAsyncDir?: string;
307
320
  panelStopReason?: "agreement";
308
321
  panelStoppedIndices?: number[];
322
+ panelDeadlines?: PanelDeadlineState[];
309
323
  judgeRunId?: string;
310
324
  judgeAsyncDir?: string;
311
325
  judgeObservation?: RunObservation;