@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 +5 -1
- 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 +43 -2
- package/package.json +1 -1
- package/src/commands.ts +17 -0
- package/src/config.ts +1 -0
- package/src/index.ts +17 -2
- package/src/lifecycle-reconcile.ts +31 -1
- package/src/orchestrator.ts +136 -9
- package/src/panel-deadlines.ts +90 -0
- package/src/result-extract.ts +1 -1
- package/src/run-builder.ts +45 -1
- package/src/run-observations.ts +3 -1
- package/src/run-store.ts +26 -1
- package/src/subagents-rpc.ts +10 -0
- package/src/types.ts +14 -0
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
package/docs/user-guide.md
CHANGED
|
@@ -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
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
|
|
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
|
|
470
|
+
code,
|
|
441
471
|
message,
|
|
442
472
|
path,
|
|
443
473
|
},
|
package/src/orchestrator.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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}
|
|
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
|
+
}
|
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;
|
package/src/run-builder.ts
CHANGED
|
@@ -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
|
-
|
|
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.",
|
package/src/run-observations.ts
CHANGED
|
@@ -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 ||
|
|
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) {
|
package/src/subagents-rpc.ts
CHANGED
|
@@ -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;
|