@alexeiled/pi-fusion 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/agents/fusion-panelist-full.md +3 -1
- package/agents/fusion-panelist-web.md +3 -1
- package/agents/fusion-panelist.md +3 -1
- package/docs/user-guide.md +56 -12
- package/package.json +1 -1
- package/skills/fusion-review/SKILL.md +2 -1
- package/src/commands.ts +17 -0
- package/src/config.ts +27 -0
- package/src/fusion-args.ts +29 -2
- package/src/fusion-rpc.ts +29 -0
- package/src/index.ts +50 -7
- package/src/lifecycle-reconcile.ts +234 -7
- package/src/orchestrator.ts +622 -79
- package/src/panel-completion.ts +65 -17
- package/src/panel-deadlines.ts +90 -0
- package/src/panel-quorum.ts +22 -0
- package/src/report.ts +85 -3
- package/src/result-extract.ts +67 -5
- package/src/run-builder.ts +129 -9
- package/src/run-observations.ts +3 -1
- package/src/run-store.ts +385 -11
- package/src/status.ts +1 -1
- package/src/subagents-rpc.ts +10 -0
- package/src/types.ts +89 -0
|
@@ -20,8 +20,11 @@ export function reconcileIndexedLifecycleResult(
|
|
|
20
20
|
|
|
21
21
|
const statusSteps = findLifecycleArray(statusPayload, "steps");
|
|
22
22
|
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
23
|
-
const statusResults =
|
|
24
|
-
|
|
23
|
+
const statusResults = authoritativeStatusLifecycleArray(
|
|
24
|
+
statusPayload,
|
|
25
|
+
statusSteps,
|
|
26
|
+
rawStatusResults,
|
|
27
|
+
);
|
|
25
28
|
const statusResult = statusResults?.[index];
|
|
26
29
|
if (statusResults && !isRecord(statusResult)) {
|
|
27
30
|
return `Subagents event includes ${label} result ${index + 1}, but status does not.`;
|
|
@@ -38,6 +41,10 @@ export interface ReconcilePanelResultsOptions {
|
|
|
38
41
|
allowedTrailingResults?: number;
|
|
39
42
|
/** Indices intentionally absent from status after early agreement. */
|
|
40
43
|
stoppedPanelIndices?: readonly number[];
|
|
44
|
+
/** A workflow deadline terminalized running child slots as timeout failures. */
|
|
45
|
+
terminalizeRunning?: boolean;
|
|
46
|
+
/** Only after the terminal-snapshot grace period, and only at a deadline. */
|
|
47
|
+
allowMissingAtDeadline?: boolean;
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
/**
|
|
@@ -64,13 +71,20 @@ export function reconcilePanelResults(
|
|
|
64
71
|
|
|
65
72
|
const rawStatusSteps = findLifecycleArray(statusPayload, "steps");
|
|
66
73
|
const rawStatusResults = findLifecycleArray(statusPayload, "results");
|
|
67
|
-
|
|
68
|
-
|
|
74
|
+
// Preserve an explicit terminal `results: []`: it is authoritative even
|
|
75
|
+
// when it contains no child results. A running empty poll, however, is not
|
|
76
|
+
// a terminal lifecycle assertion and can race a completion event.
|
|
77
|
+
const rawStatus = authoritativeStatusLifecycleArray(
|
|
78
|
+
statusPayload,
|
|
79
|
+
rawStatusSteps,
|
|
80
|
+
rawStatusResults,
|
|
81
|
+
);
|
|
69
82
|
if (!rawStatus) {
|
|
70
83
|
if (eventResults.outputs.length + eventResults.failures.length !== expectedCount) {
|
|
71
84
|
return error(
|
|
72
85
|
`Terminal subagents data described ${eventResults.outputs.length + eventResults.failures.length} of ${expectedCount} configured panel members.`,
|
|
73
86
|
"$.results",
|
|
87
|
+
"incomplete-lifecycle",
|
|
74
88
|
);
|
|
75
89
|
}
|
|
76
90
|
return eventResults;
|
|
@@ -85,8 +99,11 @@ export function reconcilePanelResults(
|
|
|
85
99
|
|
|
86
100
|
const statusResults = extractPanelResults(statusPayload, {
|
|
87
101
|
panel: profile.panel,
|
|
88
|
-
|
|
102
|
+
...(options.terminalizeRunning
|
|
103
|
+
? { terminalizeRunning: true }
|
|
104
|
+
: { completedOnly: true }),
|
|
89
105
|
limit: expectedCount,
|
|
106
|
+
...(options.allowMissingAtDeadline ? { requireStableSlotIdentity: true } : {}),
|
|
90
107
|
...(options.stoppedPanelIndices
|
|
91
108
|
? { stoppedPanelIndices: options.stoppedPanelIndices }
|
|
92
109
|
: {}),
|
|
@@ -100,6 +117,25 @@ export function reconcilePanelResults(
|
|
|
100
117
|
|
|
101
118
|
const statusCount =
|
|
102
119
|
statusResults.outputs.length + statusResults.failures.length;
|
|
120
|
+
if (
|
|
121
|
+
!options.terminalizeRunning &&
|
|
122
|
+
eventResults.outputs.length + eventResults.failures.length === expectedCount
|
|
123
|
+
) {
|
|
124
|
+
const eventSucceeded = new Set(eventResults.outputs.map((item) => item.index));
|
|
125
|
+
const statusSucceeded = new Set(statusResults.outputs.map((item) => item.index));
|
|
126
|
+
for (let index = 0; index < expectedCount; index++) {
|
|
127
|
+
const eventKnown =
|
|
128
|
+
eventSucceeded.has(index) || eventResults.failures.some((item) => item.index === index);
|
|
129
|
+
const statusKnown =
|
|
130
|
+
statusSucceeded.has(index) || statusResults.failures.some((item) => item.index === index);
|
|
131
|
+
if (eventKnown && statusKnown && eventSucceeded.has(index) !== statusSucceeded.has(index)) {
|
|
132
|
+
return error(
|
|
133
|
+
`Subagents event and status disagree about panel result ${index + 1}.`,
|
|
134
|
+
`$.steps[${index}]`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
103
139
|
const eventCount =
|
|
104
140
|
eventResults.outputs.length + eventResults.failures.length;
|
|
105
141
|
const statusIndices = new Set([
|
|
@@ -113,7 +149,14 @@ export function reconcilePanelResults(
|
|
|
113
149
|
).filter((index) => !statusIndices.has(index));
|
|
114
150
|
|
|
115
151
|
if (statusCount === expectedCount) {
|
|
116
|
-
return
|
|
152
|
+
return options.terminalizeRunning
|
|
153
|
+
? mergeTerminalDeadlineResults(
|
|
154
|
+
eventResults,
|
|
155
|
+
statusResults,
|
|
156
|
+
statusPayload,
|
|
157
|
+
resultPayload,
|
|
158
|
+
)
|
|
159
|
+
: preserveAgreementReasons(statusResults, eventResults);
|
|
117
160
|
}
|
|
118
161
|
|
|
119
162
|
const missingWereStopped =
|
|
@@ -121,9 +164,34 @@ export function reconcilePanelResults(
|
|
|
121
164
|
missingStatusIndices.length > 0 &&
|
|
122
165
|
missingStatusIndices.every((index) => stopped.has(index));
|
|
123
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
|
+
}
|
|
124
191
|
return error(
|
|
125
192
|
`Terminal subagents status described ${statusCount} of ${expectedCount} configured panel members.`,
|
|
126
193
|
"$.steps",
|
|
194
|
+
statusCount === 0 && !options.terminalizeRunning ? "unknown-result-shape" : "incomplete-lifecycle",
|
|
127
195
|
);
|
|
128
196
|
}
|
|
129
197
|
|
|
@@ -137,6 +205,124 @@ export function reconcilePanelResults(
|
|
|
137
205
|
return mergeObservations(eventResults, statusResults);
|
|
138
206
|
}
|
|
139
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Status is normally authoritative. At a workflow deadline it can still show
|
|
210
|
+
* a child as running even though the completion artifact contains that child's
|
|
211
|
+
* verified final output. Keep that verified output, normalize only genuinely
|
|
212
|
+
* unfinished slots, and retain status observations/failure details.
|
|
213
|
+
*/
|
|
214
|
+
function mergeTerminalDeadlineResults(
|
|
215
|
+
event: ExtractPanelResultsSuccess,
|
|
216
|
+
status: ExtractPanelResultsSuccess,
|
|
217
|
+
statusPayload: unknown,
|
|
218
|
+
eventPayload: unknown,
|
|
219
|
+
): ExtractPanelResultsSuccess {
|
|
220
|
+
const eventOutputs = new Map(event.outputs.map((item) => [item.index, item]));
|
|
221
|
+
const eventFailures = new Map(event.failures.map((item) => [item.index, item]));
|
|
222
|
+
const statusOutputs = new Map(status.outputs.map((item) => [item.index, item]));
|
|
223
|
+
const statusFailures = new Map(status.failures.map((item) => [item.index, item]));
|
|
224
|
+
const replaceableSlots = deadlineEventReplacementSlots(
|
|
225
|
+
statusPayload,
|
|
226
|
+
eventPayload,
|
|
227
|
+
);
|
|
228
|
+
const outputs: PanelOutput[] = [];
|
|
229
|
+
const failures: FailedPanelSummary[] = [];
|
|
230
|
+
const maxIndex = Math.max(
|
|
231
|
+
...[...eventOutputs.keys(), ...eventFailures.keys(), ...statusOutputs.keys(), ...statusFailures.keys()],
|
|
232
|
+
-1,
|
|
233
|
+
);
|
|
234
|
+
for (let index = 0; index <= maxIndex; index++) {
|
|
235
|
+
const eventOutput = eventOutputs.get(index);
|
|
236
|
+
const eventFailure = eventFailures.get(index);
|
|
237
|
+
const statusOutput = statusOutputs.get(index);
|
|
238
|
+
const statusFailure = statusFailures.get(index);
|
|
239
|
+
|
|
240
|
+
// A completed status slot is authoritative even when a stale compact
|
|
241
|
+
// event reports a failure. Event data can replace only a status slot that
|
|
242
|
+
// deadline handling normalized from a nonterminal state, and only when
|
|
243
|
+
// both records identify the same public panel-N slot.
|
|
244
|
+
if (!replaceableSlots.has(index)) {
|
|
245
|
+
if (statusOutput) outputs.push(statusOutput);
|
|
246
|
+
else if (statusFailure) failures.push(statusFailure);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (eventOutput) {
|
|
251
|
+
outputs.push(withObservation(eventOutput, statusFailure?.observation));
|
|
252
|
+
} else if (eventFailure) {
|
|
253
|
+
failures.push(withObservation(eventFailure, statusFailure?.observation));
|
|
254
|
+
} else if (statusFailure) {
|
|
255
|
+
failures.push(statusFailure);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, outputs, failures, ...(event.runId ? { runId: event.runId } : {}) };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Deadline reconciliation never trusts compact-event array order: failed
|
|
263
|
+
* children can be omitted or arrive late. A compact event may replace only a
|
|
264
|
+
* status record terminalized from a nonterminal state when both explicitly
|
|
265
|
+
* name the same public workflow slot.
|
|
266
|
+
*/
|
|
267
|
+
function deadlineEventReplacementSlots(
|
|
268
|
+
statusPayload: unknown,
|
|
269
|
+
eventPayload: unknown,
|
|
270
|
+
): ReadonlySet<number> {
|
|
271
|
+
const statusResults =
|
|
272
|
+
findLifecycleArray(statusPayload, "steps") ??
|
|
273
|
+
findLifecycleArray(statusPayload, "results");
|
|
274
|
+
const eventResults = findLifecycleArray(eventPayload, "results");
|
|
275
|
+
if (!statusResults || !eventResults) return new Set<number>();
|
|
276
|
+
|
|
277
|
+
const nonterminalStatusSlots = new Set<number>();
|
|
278
|
+
for (const result of statusResults) {
|
|
279
|
+
const slot = stablePanelSlot(result);
|
|
280
|
+
if (slot !== undefined && isNonterminalLifecycleResult(result)) {
|
|
281
|
+
nonterminalStatusSlots.add(slot);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const matchingEventSlots = new Set<number>();
|
|
286
|
+
for (const result of eventResults) {
|
|
287
|
+
const slot = stablePanelSlot(result);
|
|
288
|
+
if (slot !== undefined && nonterminalStatusSlots.has(slot)) {
|
|
289
|
+
matchingEventSlots.add(slot);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return matchingEventSlots;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function stablePanelSlot(result: unknown): number | undefined {
|
|
296
|
+
if (!isRecord(result)) return undefined;
|
|
297
|
+
// Result extraction already accepts these lifecycle fields as zero-based
|
|
298
|
+
// public slots. Keep deadline matching exactly aligned; a numeric 1 must
|
|
299
|
+
// mean panel slot 1, never a guessed one-based panel-1.
|
|
300
|
+
for (const candidate of [result.index, result.taskIndex, result.stepIndex]) {
|
|
301
|
+
if (typeof candidate === "number" && Number.isInteger(candidate) && candidate >= 0) {
|
|
302
|
+
return candidate;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
const key = firstString(
|
|
306
|
+
result.key,
|
|
307
|
+
result.taskKey,
|
|
308
|
+
result.stepKey,
|
|
309
|
+
result.agent,
|
|
310
|
+
);
|
|
311
|
+
const match = key?.match(/^panel-([1-9]\d*)$/);
|
|
312
|
+
return match ? Number(match[1]) - 1 : undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isNonterminalLifecycleResult(result: unknown): boolean {
|
|
316
|
+
if (!isRecord(result)) return false;
|
|
317
|
+
const status = firstString(result.status, result.state);
|
|
318
|
+
return (
|
|
319
|
+
status === "running" ||
|
|
320
|
+
status === "active" ||
|
|
321
|
+
status === "pending" ||
|
|
322
|
+
status === "queued"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
140
326
|
function preserveAgreementReasons(
|
|
141
327
|
status: ExtractPanelResultsSuccess,
|
|
142
328
|
event: ExtractPanelResultsSuccess,
|
|
@@ -214,6 +400,46 @@ function findLifecycleArray(
|
|
|
214
400
|
return undefined;
|
|
215
401
|
}
|
|
216
402
|
|
|
403
|
+
function authoritativeStatusLifecycleArray(
|
|
404
|
+
payload: unknown,
|
|
405
|
+
steps: readonly unknown[] | undefined,
|
|
406
|
+
results: readonly unknown[] | undefined,
|
|
407
|
+
): readonly unknown[] | undefined {
|
|
408
|
+
const lifecycle = steps ?? results;
|
|
409
|
+
if (!lifecycle || lifecycle.length > 0 || isTerminalLifecyclePayload(payload)) {
|
|
410
|
+
return lifecycle;
|
|
411
|
+
}
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isTerminalLifecyclePayload(payload: unknown): boolean {
|
|
416
|
+
if (!isRecord(payload)) return false;
|
|
417
|
+
const state = firstString(payload.state, payload.status);
|
|
418
|
+
const textState = firstString(payload.text)?.match(
|
|
419
|
+
/(?:^|\n)(?:State|Status):\s*([^\n\r]+)/i,
|
|
420
|
+
)?.[1]?.trim();
|
|
421
|
+
if (
|
|
422
|
+
state === "complete" ||
|
|
423
|
+
textState === "complete" ||
|
|
424
|
+
textState === "completed" ||
|
|
425
|
+
textState === "done" ||
|
|
426
|
+
textState === "failed" ||
|
|
427
|
+
textState === "paused" ||
|
|
428
|
+
textState === "detached" ||
|
|
429
|
+
state === "completed" ||
|
|
430
|
+
state === "done" ||
|
|
431
|
+
state === "failed" ||
|
|
432
|
+
state === "paused" ||
|
|
433
|
+
state === "detached"
|
|
434
|
+
) {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
if (isRecord(payload.details) && isTerminalLifecyclePayload(payload.details)) {
|
|
438
|
+
return true;
|
|
439
|
+
}
|
|
440
|
+
return isRecord(payload.data) && isTerminalLifecyclePayload(payload.data);
|
|
441
|
+
}
|
|
442
|
+
|
|
217
443
|
function isFailedLifecycleResult(result: Record<string, unknown>): boolean {
|
|
218
444
|
if (result.success === false) return true;
|
|
219
445
|
if (result.timedOut === true || result.interrupted === true) return true;
|
|
@@ -236,11 +462,12 @@ function unknownArray(value: unknown): readonly unknown[] | undefined {
|
|
|
236
462
|
function error(
|
|
237
463
|
message: string,
|
|
238
464
|
path: string,
|
|
465
|
+
code: "unknown-result-shape" | "incomplete-lifecycle" = "unknown-result-shape",
|
|
239
466
|
): ExtractPanelResultsResult {
|
|
240
467
|
return {
|
|
241
468
|
ok: false,
|
|
242
469
|
error: {
|
|
243
|
-
code
|
|
470
|
+
code,
|
|
244
471
|
message,
|
|
245
472
|
path,
|
|
246
473
|
},
|