@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.
@@ -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
- statusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
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
- const rawStatus =
68
- rawStatusSteps ?? (rawStatusResults?.length ? rawStatusResults : undefined);
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
- completedOnly: true,
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 preserveAgreementReasons(statusResults, eventResults);
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: "unknown-result-shape",
470
+ code,
244
471
  message,
245
472
  path,
246
473
  },