pi-legion 3.0.0 → 3.0.3

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.
@@ -37,7 +37,17 @@ function emptyUsage(): Usage {
37
37
 
38
38
  /** Mutable clock used while folding events; not part of ParsedSession. */
39
39
  export interface GenerationClock {
40
+ /** Nested Unix-ms timestamp of the message that opened this generation window. */
40
41
  awaitingSince?: number;
42
+ /**
43
+ * Same window edge expressed as the enclosing entry's write instant.
44
+ *
45
+ * Sessions may carry only the nested timestamp, only the entry ISO timestamp,
46
+ * or both. Measuring an end against a start from a different axis yields a
47
+ * nonsense span, so each window records both edges and `applyAssistant`
48
+ * pairs like with like.
49
+ */
50
+ awaitingWall?: number;
41
51
  }
42
52
 
43
53
  export function emptyParsedSession(): ParsedSession {
@@ -126,10 +136,13 @@ function accumulateUsage(
126
136
  }
127
137
 
128
138
  /**
129
- * Turn-boundary timestamps for the current-turn elapsed display.
139
+ * Turn-boundary timestamp for the current-turn elapsed display.
130
140
  *
131
- * The nested message `timestamp` is Unix ms (session-format.md); fall back to
132
- * the ISO 8601 entry timestamp for entries written without the nested value.
141
+ * Prefers the nested message `timestamp` (Unix ms, session-format.md) — for the
142
+ * elapsed display the moment generation began is exactly what we want — and
143
+ * falls back to the ISO 8601 entry timestamp for entries written without it.
144
+ * Generation *duration* is measured elsewhere (`applyAssistant`): the nested
145
+ * value marks a start, not a completion, and `durationMs` carries the length.
133
146
  */
134
147
  function messageTimestamp(
135
148
  event: Record<string, unknown>,
@@ -145,6 +158,19 @@ function messageTimestamp(
145
158
  return undefined;
146
159
  }
147
160
 
161
+ /**
162
+ * The instant a message was written, on the same Unix-ms axis as
163
+ * `message.timestamp`. Used for the generation clock so start and end share a
164
+ * basis; a session that only carries the entry ISO timestamp would otherwise
165
+ * mix a wall-clock instant into a Unix-ms subtraction.
166
+ */
167
+ function entryWrittenAt(event: Record<string, unknown>): number | undefined {
168
+ const outer = event.timestamp;
169
+ if (typeof outer !== "string") return undefined;
170
+ const ms = Date.parse(outer);
171
+ return Number.isFinite(ms) ? ms : undefined;
172
+ }
173
+
148
174
  /** Find (or create) the turn an assistant/toolResult message belongs to. */
149
175
  function currentTurn(parsed: ParsedSession): TurnRecord {
150
176
  const existing = parsed.turns.at(-1);
@@ -163,6 +189,7 @@ function applyAssistant(
163
189
  parsed: ParsedSession,
164
190
  message: Record<string, unknown>,
165
191
  timestamp: number | undefined,
192
+ wall: number | undefined,
166
193
  clock: GenerationClock,
167
194
  ): void {
168
195
  const turn = currentTurn(parsed);
@@ -181,7 +208,21 @@ function applyAssistant(
181
208
  });
182
209
  if (timestamp !== undefined) turn.endedAt = timestamp;
183
210
 
184
- if (
211
+ // Generation time spans the wait for the model plus the model call itself. Two
212
+ // strictly paired sources, never mixed:
213
+ // 1. `durationMs` — an exact length reported by the provider, axis-free.
214
+ // 2. entry-to-entry (`wall` − `awaitingWall`) — both edges are write instants,
215
+ // so a session that omits nested timestamps still measures correctly.
216
+ // Subtracting the nested `message.timestamp` is NOT the completion instant: it
217
+ // marks when generation began, so it captures only prompt→first-token latency
218
+ // (2-7ms on real sessions). It survives as a last resort for synthetic or
219
+ // legacy sessions that carry no entry timestamps at all.
220
+ const measured = message.durationMs;
221
+ if (typeof measured === "number" && Number.isFinite(measured) && measured >= 0) {
222
+ parsed.generationMs += measured;
223
+ } else if (wall !== undefined && clock.awaitingWall !== undefined && wall >= clock.awaitingWall) {
224
+ parsed.generationMs += wall - clock.awaitingWall;
225
+ } else if (
185
226
  timestamp !== undefined &&
186
227
  clock.awaitingSince !== undefined &&
187
228
  timestamp >= clock.awaitingSince
@@ -189,6 +230,7 @@ function applyAssistant(
189
230
  parsed.generationMs += timestamp - clock.awaitingSince;
190
231
  }
191
232
  clock.awaitingSince = undefined;
233
+ clock.awaitingWall = undefined;
192
234
 
193
235
  accumulateUsage(parsed.usage, message);
194
236
  if (typeof message.model === "string") parsed.model = message.model;
@@ -201,6 +243,7 @@ function applyToolResult(
201
243
  parsed: ParsedSession,
202
244
  message: Record<string, unknown>,
203
245
  timestamp: number | undefined,
246
+ wall: number | undefined,
204
247
  clock: GenerationClock,
205
248
  ): void {
206
249
  const turn = parsed.turns.at(-1);
@@ -212,6 +255,7 @@ function applyToolResult(
212
255
  }
213
256
  // Tool completion starts the next generation window (model resumes).
214
257
  if (timestamp !== undefined) clock.awaitingSince = timestamp;
258
+ if (wall !== undefined) clock.awaitingWall = wall;
215
259
  }
216
260
 
217
261
  /**
@@ -288,6 +332,9 @@ function applyEvent(
288
332
  const message = event.message as Record<string, unknown> | undefined;
289
333
  if (!message || typeof message !== "object") return;
290
334
  const ts = messageTimestamp(event, message);
335
+ // The entry write instant, on the same Unix-ms axis as `message.timestamp`.
336
+ // Generation timing uses it so both window edges share one basis.
337
+ const wall = entryWrittenAt(event);
291
338
 
292
339
  switch (message.role) {
293
340
  case "user":
@@ -300,13 +347,14 @@ function applyEvent(
300
347
  });
301
348
  // User prompt opens a generation window.
302
349
  if (ts !== undefined) clock.awaitingSince = ts;
350
+ if (wall !== undefined) clock.awaitingWall = wall;
303
351
  break;
304
352
  case "assistant":
305
- applyAssistant(parsed, message, ts, clock);
353
+ applyAssistant(parsed, message, ts, wall, clock);
306
354
  break;
307
355
  case "toolResult":
308
356
  case "tool":
309
- applyToolResult(parsed, message, ts, clock);
357
+ applyToolResult(parsed, message, ts, wall, clock);
310
358
  break;
311
359
  default:
312
360
  break;
@@ -529,6 +577,25 @@ export const NO_ASSISTANT_ABORT_REASON = "no assistant message for the last prom
529
577
  export const PARENT_SESSION_SHUTDOWN_ABORT_REASON =
530
578
  "parent session shutdown retired the child before it produced an assistant message";
531
579
 
580
+ /**
581
+ * The parent turn ended and the abort signal carried no reason (#227 D3b).
582
+ *
583
+ * Upstream writes a reason-less `AbortController` cancellation as
584
+ * `stopReason:"error"` with a bare `"This operation was aborted"` message, which
585
+ * used to be indistinguishable from a real provider failure. The status stays
586
+ * `aborted`; only the reason becomes self-describing.
587
+ */
588
+ export const ABORT_WITHOUT_REASON =
589
+ "parent turn ended (abort signal without a reason)";
590
+
591
+ /**
592
+ * The exact messages `AbortSignal`/`AbortController` produce with no reason.
593
+ * Anchored to the whole message so a longer, meaningful error (a provider
594
+ * message that merely mentions aborting) does not get rewritten.
595
+ */
596
+ const REASONLESS_ABORT_MESSAGE =
597
+ /^(this operation was aborted|the operation was aborted|operation aborted)$/i;
598
+
532
599
  /** True when the last turn (or the whole file) has no assistant message. */
533
600
  export function lastTurnHasNoAssistant(parsed: ParsedSession): boolean {
534
601
  const last = parsed.turns.at(-1);
@@ -631,11 +698,20 @@ function outcomeFromFinalMessage(
631
698
  // F29: a graceful interrupt surfaces as `error` with an abort-ish message.
632
699
  const msg = final.errorMessage ?? "";
633
700
  if (/abort/i.test(msg)) {
701
+ // #227 D3b: a reason-less AbortController cancellation reaches us as
702
+ // the bare engine message. Name it as the parent's abort instead of
703
+ // echoing text that reads like a provider failure. Anything carrying
704
+ // more (notably a `timeout`, or a longer provider sentence) keeps the
705
+ // original message — status is `aborted` either way.
706
+ const reason =
707
+ REASONLESS_ABORT_MESSAGE.test(msg.trim()) && !/timeout/i.test(msg)
708
+ ? ABORT_WITHOUT_REASON
709
+ : msg;
634
710
  return {
635
711
  status: "aborted",
636
712
  stopReason: "error",
637
713
  errorMessage: msg,
638
- reason: msg,
714
+ reason,
639
715
  ...base,
640
716
  };
641
717
  }
@@ -364,6 +364,13 @@ export interface ChildRecord {
364
364
  thinking?: string | false;
365
365
  /** Models that were requested but cannot be represented by the RPC child. */
366
366
  modelDropped?: string[];
367
+ /**
368
+ * Backend this child was actually spawned on, as arbitrated by
369
+ * `Orchestrator.launch` (#209). Optional because run.json records written
370
+ * before the field existed have no value — readers must render those as
371
+ * unknown, never guess a backend.
372
+ */
373
+ spawnMode?: SpawnMode;
367
374
  }
368
375
 
369
376
  export interface RunRecord {
@@ -373,8 +380,11 @@ export interface RunRecord {
373
380
  cwd: string;
374
381
 
375
382
  herdr: {
376
- /** Current transport marker under the historic run.json key. */
377
- supervisor?: "rpc";
383
+ /**
384
+ * Transport marker under the historic run.json key: the arbitrated backend
385
+ * of the run's own process (#209). Absent on legacy records.
386
+ */
387
+ supervisor?: SpawnMode;
378
388
  /** Reserved for M2 tree identity. */
379
389
  node_id?: string;
380
390
  };
@@ -482,6 +492,11 @@ export interface Handle {
482
492
  * silently defeated the ownership audit.
483
493
  */
484
494
  child: ChildRecord;
495
+ /**
496
+ * Backend the orchestrator actually chose for this launch (arbitration
497
+ * result, #209). Callers render this instead of assuming `rpc`.
498
+ */
499
+ spawnMode: SpawnMode;
485
500
  }
486
501
 
487
502
  // =============================================================================