pi-famulus 0.1.2 → 0.1.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.
@@ -6,8 +6,23 @@
6
6
  * candidate set (scopedModels whitelist when non-empty, else all available).
7
7
  *
8
8
  * Spec grammar: `[provider/|provider:]id[:thinking]`
9
- * - thinking suffix: minimal|low|medium|high|xhigh (unrecognized suffixes
10
- * are treated as part of the id, e.g. OpenRouter's ":exacto")
9
+ * - thinking suffix: one of VALID_THINKING_LEVELS (pi core parity;
10
+ * src/thinking-levels.ts is the single source of truth). Unrecognized
11
+ * suffixes are NOT silently kept in the id: when the full spec fails to
12
+ * match, the resolver retries without the trailing ":suffix" and adapts
13
+ * with a warning — the same semantics as pi's
14
+ * parseModelPattern(allowInvalidThinkingLevelFallback). The retry only
15
+ * fires on a full-spec no-match, so literal ids whose trailing ':segment'
16
+ * is not a valid level (OpenRouter ":exacto") still win at exact match.
17
+ * Note the converse: an id literally ending in a valid level (":high")
18
+ * is read as a thinking override, never as a literal id.
19
+ * A "provider:id" separator is never a thinking suffix: when the
20
+ * trailing ':' is the spec's first separator and its prefix is a known
21
+ * provider (case-insensitive; candidate.provider or the first '/' segment
22
+ * of candidate.id), the colon is left in the base ("openai:off" /
23
+ * "openai:max" / "openai:nonexistent" stay hard no-matches). Detection
24
+ * runs before valid-level splitting so a recognized level cannot bypass
25
+ * the guard. Bare "gpt-5.2:off" and "openai:gpt-5.2:off" still split.
11
26
  * - provider prefix: "provider/id" or "provider:id" both accepted
12
27
  * - bare id: exact unique match, else case-insensitive substring on id/name
13
28
  */
@@ -19,10 +34,24 @@ export interface ModelCandidate {
19
34
  }
20
35
 
21
36
  export type ModelResolution =
22
- | { ok: true; provider: string; id: string; thinking?: string }
23
- | { ok: false; error: "no-match" | "ambiguous"; candidates: string[]; thinking?: string };
37
+ | { ok: true; provider: string; id: string; thinking?: string; warning?: string }
38
+ | {
39
+ ok: false;
40
+ error: "no-match" | "ambiguous";
41
+ candidates: string[];
42
+ thinking?: string;
43
+ /** Set when a trailing ":suffix" was stripped and retried unsuccessfully. */
44
+ suffixHint?: string;
45
+ };
24
46
 
25
- const THINKING_LEVELS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
47
+ /**
48
+ * Thinking levels pi core accepts — re-exported from the single source of
49
+ * truth (src/thinking-levels.ts) for convenience of resolver callers.
50
+ */
51
+ export { VALID_THINKING_LEVELS } from "../thinking-levels";
52
+ import { VALID_THINKING_LEVELS } from "../thinking-levels";
53
+
54
+ const THINKING_LEVELS = new Set<string>(VALID_THINKING_LEVELS);
26
55
 
27
56
  function label(c: ModelCandidate): string {
28
57
  return `${c.provider}/${c.id}`;
@@ -49,10 +78,42 @@ function splitProviderPrefix(spec: string): { provider: string; id: string } | n
49
78
  return null;
50
79
  }
51
80
 
52
- export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
53
- const { base, thinking } = splitThinkingSuffix(spec.trim());
81
+ /**
82
+ * Provider names the separator guard recognizes: each candidate's `provider`
83
+ * field, plus the first `/` segment of its id. OpenRouter ids look like
84
+ * `openai/gpt-5.2:exacto`; if the scoped whitelist omits the openai provider
85
+ * itself, that nested prefix still has to count, or `openai:nonexistent`
86
+ * retries as `openai` and unique-fuzzy-matches the OpenRouter id.
87
+ */
88
+ function knownProviders(candidates: ModelCandidate[]): string[] {
89
+ const out: string[] = [];
90
+ for (const c of candidates) {
91
+ out.push(c.provider);
92
+ const slash = c.id.indexOf("/");
93
+ if (slash > 0) out.push(c.id.slice(0, slash));
94
+ }
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * True when the ':' at `idx` is the "provider:id" separator rather than a
100
+ * thinking suffix: it is the spec's first separator and the text before it
101
+ * names a known provider (case-insensitive). Stripping it would retry the
102
+ * bare provider name, which fuzzy-matches unrelated ids (e.g.
103
+ * "openai:nonexistent" → the OpenRouter id "openai/gpt-5.2:exacto") and
104
+ * silently drops the requested id.
105
+ */
106
+ function isProviderSeparator(spec: string, idx: number, providers: readonly string[]): boolean {
107
+ const provider = spec.slice(0, idx);
108
+ if (/[/:]/.test(provider)) return false;
109
+ const needle = provider.toLowerCase();
110
+ return providers.some((p) => p.toLowerCase() === needle);
111
+ }
112
+
113
+ /** The three matching rules, shared by both resolution passes. */
114
+ function matchBase(base: string, candidates: ModelCandidate[]): ModelResolution {
54
115
  if (base.length === 0) {
55
- return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
116
+ return { ok: false, error: "no-match", candidates: candidates.map(label) };
56
117
  }
57
118
 
58
119
  // 1) provider-qualified exact match
@@ -61,17 +122,17 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
61
122
  const hit = candidates.find(
62
123
  (c) => c.provider === qualified.provider && c.id === qualified.id,
63
124
  );
64
- if (hit) return { ok: true, provider: hit.provider, id: hit.id, thinking };
125
+ if (hit) return { ok: true, provider: hit.provider, id: hit.id };
65
126
  // Fall through: maybe the whole thing is a bare id containing a separator.
66
127
  }
67
128
 
68
129
  // 2) bare-id exact match (must be unique across providers)
69
130
  const exact = candidates.filter((c) => c.id === base);
70
131
  if (exact.length === 1) {
71
- return { ok: true, provider: exact[0].provider, id: exact[0].id, thinking };
132
+ return { ok: true, provider: exact[0].provider, id: exact[0].id };
72
133
  }
73
134
  if (exact.length > 1) {
74
- return { ok: false, error: "ambiguous", candidates: exact.map(label), thinking };
135
+ return { ok: false, error: "ambiguous", candidates: exact.map(label) };
75
136
  }
76
137
 
77
138
  // 3) case-insensitive substring on id or display name
@@ -82,26 +143,83 @@ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): Mo
82
143
  (c.name !== undefined && c.name.toLowerCase().includes(needle)),
83
144
  );
84
145
  if (fuzzy.length === 1) {
85
- return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id, thinking };
146
+ return { ok: true, provider: fuzzy[0].provider, id: fuzzy[0].id };
86
147
  }
87
148
  if (fuzzy.length > 1) {
88
- return { ok: false, error: "ambiguous", candidates: fuzzy.map(label), thinking };
149
+ return { ok: false, error: "ambiguous", candidates: fuzzy.map(label) };
150
+ }
151
+ return { ok: false, error: "no-match", candidates: candidates.map(label) };
152
+ }
153
+
154
+ export function resolveModelSpec(spec: string, candidates: ModelCandidate[]): ModelResolution {
155
+ const trimmed = spec.trim();
156
+ const providers = knownProviders(candidates);
157
+ const lastColon = trimmed.lastIndexOf(":");
158
+ // Known-provider / first-colon detection runs BEFORE valid thinking-suffix
159
+ // splitting. Otherwise a recognized level (off/max, newly in this PR's
160
+ // parity list) strips the separator, matchBase("openai") fuzzy-matches an
161
+ // unrelated id, and the retry guard at the bottom never sees the spec.
162
+ const skipThinkingSplit =
163
+ lastColon > 0 &&
164
+ lastColon < trimmed.length - 1 &&
165
+ isProviderSeparator(trimmed, lastColon, providers);
166
+ const { base, thinking } = skipThinkingSplit
167
+ ? { base: trimmed, thinking: undefined }
168
+ : splitThinkingSuffix(trimmed);
169
+ const first = matchBase(base, candidates);
170
+ if (first.ok || thinking !== undefined || first.error === "ambiguous") {
171
+ // Success, a valid thinking suffix, or an ambiguity: all final. Ambiguity
172
+ // is semantic, not syntax — report it instead of adapting past it.
173
+ return { ...first, thinking };
174
+ }
175
+
176
+ // Unknown trailing ":suffix" on a no-match: mirror pi core's
177
+ // parseModelPattern(allowInvalidThinkingLevelFallback) — strip the suffix,
178
+ // retry, and adapt with a warning instead of failing the whole call. The
179
+ // retry only fires when the full spec matched nothing, so literal ids
180
+ // containing a colon (OpenRouter ":exacto") are unaffected.
181
+ const idx = trimmed.lastIndexOf(":");
182
+ if (idx <= 0 || idx === trimmed.length - 1) return first;
183
+ if (isProviderSeparator(trimmed, idx, providers)) return first;
184
+ const suffix = trimmed.slice(idx + 1);
185
+ const retriedBase = trimmed.slice(0, idx);
186
+ const retried = matchBase(retriedBase, candidates);
187
+ if (retried.ok) {
188
+ return {
189
+ ...retried,
190
+ warning:
191
+ `unknown thinking level "${suffix}" in model spec "${trimmed}"; ` +
192
+ `using the model's default thinking ` +
193
+ `(valid levels: ${VALID_THINKING_LEVELS.join(", ")})`,
194
+ };
89
195
  }
90
- return { ok: false, error: "no-match", candidates: candidates.map(label), thinking };
196
+ return {
197
+ ...retried,
198
+ suffixHint:
199
+ `":${suffix}" is not a valid thinking level ` +
200
+ `(valid: ${VALID_THINKING_LEVELS.join(", ")}); ` +
201
+ `retried without it as "${retriedBase}"`,
202
+ };
91
203
  }
92
204
 
93
205
  /** Render a resolution failure as an actionable error message. */
94
206
  export function modelResolutionError(spec: string, res: ModelResolution & { ok: false }): string {
95
207
  const list = res.candidates.slice(0, 20).join(", ");
96
208
  const more = res.candidates.length > 20 ? `, … +${res.candidates.length - 20} more` : "";
209
+ const hint = res.suffixHint ? ` (${res.suffixHint})` : "";
97
210
  if (res.error === "ambiguous") {
211
+ // With a suffixHint the ambiguity belongs to the retried base, not the
212
+ // full spec — say so, and name the dropped suffix either way.
98
213
  return (
99
- `model spec "${spec}" is ambiguous (matches: ${list}${more}). ` +
214
+ (res.suffixHint
215
+ ? `model spec "${spec}" is ambiguous on the retried base`
216
+ : `model spec "${spec}" is ambiguous`) +
217
+ ` (matches: ${list}${more}).${hint} ` +
100
218
  `Qualify with "provider/<id>".`
101
219
  );
102
220
  }
103
221
  return (
104
- `model spec "${spec}" matched nothing. ` +
222
+ `model spec "${spec}" matched nothing.${hint} ` +
105
223
  `Available: ${list || "none"}${more}. ` +
106
224
  `Use subagent({action:"models"}) to list selectable models.`
107
225
  );
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Overrun wake inputs (design doc §4.6 child lifecycle): which foreground
3
+ * shell each child is blocked on, and the builder that turns a runner
4
+ * overrun tick into the wake payload.
5
+ *
6
+ * Zero pi dependency; the output-file stat is injected so tests can drive it.
7
+ */
8
+ import { formatSubagentOverrun, type SubagentOverrunInfo } from "../format";
9
+ import type { FormattedWake, OverrunShell } from "../wake";
10
+ import type { ConversationTurn } from "./types";
11
+
12
+ export interface ChildShell {
13
+ taskId: string;
14
+ command: string;
15
+ startedAt: number;
16
+ outputPath: string;
17
+ }
18
+
19
+ /** Output that changed within this window counts as growing on a first reading. */
20
+ export const OUTPUT_GROWING_WINDOW_MS = 60_000;
21
+
22
+ /**
23
+ * Foreground shells per child. Child bash records a shell when the manager
24
+ * starts it and clears it when the call returns or throws; a child runs at
25
+ * most one foreground shell at a time (no background bash in children).
26
+ */
27
+ export class ChildShellTracker {
28
+ private readonly shells = new Map<string, ChildShell>();
29
+ /** Output size at the previous overrun reading, per task. */
30
+ private readonly sizes = new Map<string, number>();
31
+
32
+ start(childId: string, shell: ChildShell): void {
33
+ this.shells.set(childId, shell);
34
+ }
35
+
36
+ end(childId: string, taskId: string): void {
37
+ if (this.shells.get(childId)?.taskId === taskId) this.shells.delete(childId);
38
+ this.sizes.delete(taskId);
39
+ }
40
+
41
+ current(childId: string): ChildShell | undefined {
42
+ return this.shells.get(childId);
43
+ }
44
+
45
+ /** Record a size reading and return the previous one for the same task. */
46
+ swapSize(taskId: string, size: number): number | undefined {
47
+ const previous = this.sizes.get(taskId);
48
+ this.sizes.set(taskId, size);
49
+ return previous;
50
+ }
51
+ }
52
+
53
+ /** What the runner knows at an overrun tick. */
54
+ export interface OverrunTick {
55
+ childId: string;
56
+ elapsedMs: number;
57
+ budgetMs: number;
58
+ reminder: number;
59
+ nextReminderMs: number;
60
+ lastEventAt: number;
61
+ /** Time left until the opt-in hard ceiling aborts the child; null when none is set. */
62
+ hardRemainingMs: number | null;
63
+ }
64
+
65
+ export interface OverrunInfoDeps {
66
+ now: () => number;
67
+ /** Run id and display name; undefined once the child's run is gone. */
68
+ lookupChild: (childId: string) => { runId: string; name: string } | undefined;
69
+ conversation: (childId: string) => ConversationTurn[];
70
+ shells: ChildShellTracker;
71
+ /** Output file size and mtime; null when it cannot be read. */
72
+ stat: (path: string) => { size: number; mtimeMs: number } | null;
73
+ }
74
+
75
+ function lastActivityText(turns: ConversationTurn[]): string {
76
+ const last = turns[turns.length - 1];
77
+ return last ? `${last.role}: ${last.text}` : "";
78
+ }
79
+
80
+ function describeShell(deps: OverrunInfoDeps, shell: ChildShell): OverrunShell {
81
+ const now = deps.now();
82
+ const st = deps.stat(shell.outputPath);
83
+ let outputBytes: number | null = null;
84
+ let outputIdleMs: number | null = null;
85
+ let growing: boolean | null = null;
86
+ if (st) {
87
+ outputBytes = st.size;
88
+ outputIdleMs = Math.max(0, now - st.mtimeMs);
89
+ const previous = deps.shells.swapSize(shell.taskId, st.size);
90
+ growing = (previous !== undefined && st.size > previous) || outputIdleMs < OUTPUT_GROWING_WINDOW_MS;
91
+ }
92
+ return {
93
+ taskId: shell.taskId,
94
+ command: shell.command,
95
+ elapsedMs: Math.max(0, now - shell.startedAt),
96
+ outputPath: shell.outputPath,
97
+ outputBytes,
98
+ outputIdleMs,
99
+ growing,
100
+ };
101
+ }
102
+
103
+ export function buildOverrunInfo(deps: OverrunInfoDeps, tick: OverrunTick): SubagentOverrunInfo | undefined {
104
+ const child = deps.lookupChild(tick.childId);
105
+ if (!child) return undefined;
106
+ const shell = deps.shells.current(tick.childId);
107
+ return {
108
+ runId: child.runId,
109
+ childId: tick.childId,
110
+ name: child.name,
111
+ elapsedMs: tick.elapsedMs,
112
+ budgetMs: tick.budgetMs,
113
+ reminder: tick.reminder,
114
+ nextReminderMs: tick.nextReminderMs,
115
+ ...(tick.hardRemainingMs !== null ? { hardCeilingMs: tick.hardRemainingMs } : {}),
116
+ lastActivity: {
117
+ agoMs: Math.max(0, deps.now() - tick.lastEventAt),
118
+ text: lastActivityText(deps.conversation(tick.childId)),
119
+ },
120
+ ...(shell ? { shell: describeShell(deps, shell) } : {}),
121
+ };
122
+ }
123
+
124
+ export interface OverrunNotifierDeps {
125
+ now: () => number;
126
+ registry: {
127
+ list(): { runId: string; children: { childId: string; name: string }[] }[];
128
+ handle(childId: string): { conversation(): ConversationTurn[] } | undefined;
129
+ };
130
+ shells: ChildShellTracker;
131
+ stat: OverrunInfoDeps["stat"];
132
+ /** Parent wake (NotifyCenter.notify). */
133
+ notify: (wake: FormattedWake) => void;
134
+ logEvent: (type: string, fields: Record<string, unknown>) => void;
135
+ }
136
+
137
+ /**
138
+ * The runner's onOverrun: build the payload, log `agent.overrun`, wake the
139
+ * parent. A child whose run is already gone produces neither.
140
+ */
141
+ export function createOverrunNotifier(deps: OverrunNotifierDeps): (tick: OverrunTick) => void {
142
+ const infoDeps: OverrunInfoDeps = {
143
+ now: deps.now,
144
+ lookupChild: (childId) => {
145
+ for (const run of deps.registry.list()) {
146
+ const child = run.children.find((c) => c.childId === childId);
147
+ if (child) return { runId: run.runId, name: child.name };
148
+ }
149
+ return undefined;
150
+ },
151
+ conversation: (childId) => deps.registry.handle(childId)?.conversation() ?? [],
152
+ shells: deps.shells,
153
+ stat: deps.stat,
154
+ };
155
+ return (tick) => {
156
+ const info = buildOverrunInfo(infoDeps, tick);
157
+ if (!info) return;
158
+ const shell = info.shell;
159
+ deps.logEvent("agent.overrun", {
160
+ child_id: info.childId,
161
+ run_id: info.runId,
162
+ reminder: info.reminder,
163
+ elapsed_ms: info.elapsedMs,
164
+ budget_ms: info.budgetMs,
165
+ ...(shell
166
+ ? {
167
+ shell_task_id: shell.taskId,
168
+ shell_elapsed_ms: shell.elapsedMs,
169
+ ...(shell.outputBytes !== null ? { output_bytes: shell.outputBytes } : {}),
170
+ ...(shell.growing !== null ? { growing: shell.growing } : {}),
171
+ }
172
+ : {}),
173
+ });
174
+ deps.notify(formatSubagentOverrun(info));
175
+ };
176
+ }
@@ -137,13 +137,13 @@ function resolveModel(deps: PiRuntimeDeps, req: ChildRunRequest): ResolvedModel
137
137
  if (!res.ok) throw new Error(modelResolutionError(req.model, res));
138
138
  const model = findModel(res.provider, res.id);
139
139
  if (!model) throw new Error(`model ${res.provider}/${res.id} is not usable in this session`);
140
- return { model, thinkingOverride: res.thinking };
140
+ return { model, thinkingOverride: res.thinking, warning: res.warning };
141
141
  }
142
142
  if (req.agent.model !== undefined) {
143
143
  const res = resolveModelSpec(req.agent.model, candidates);
144
144
  if (res.ok) {
145
145
  const model = findModel(res.provider, res.id);
146
- if (model) return { model, thinkingOverride: res.thinking };
146
+ if (model) return { model, thinkingOverride: res.thinking, warning: res.warning };
147
147
  }
148
148
  return {
149
149
  model: deps.getParentModel(),
@@ -10,8 +10,10 @@
10
10
  * - lineage: v1 = same runId.
11
11
  *
12
12
  * Wiring contract: the runner must be constructed with
13
- * `acquire: (req) => registry.admitChild(req.childId)` so every generation
14
- * (initial start and resume) passes through admission. startChild() wires the
13
+ * `acquire: (req, ticket) => registry.admitChild(req.childId, ticket)` so every
14
+ * generation (initial start and resume) passes through admission. Forward the
15
+ * ticket: without it admitChild falls back to the handle's current status and
16
+ * a stale queued request can admit a newer resume. startChild() wires the
15
17
  * first generation's result; admitChild() wires subsequent generations.
16
18
  *
17
19
  * Zero pi dependency.
@@ -47,6 +49,8 @@ export interface RunRecord {
47
49
  result?: ChildResult;
48
50
  startedAt: number;
49
51
  endedAt?: number;
52
+ /** User turns so far: 1 for the launch, +1 per accepted resume. */
53
+ turn?: number;
50
54
  }[];
51
55
  status: "running" | "completed" | "partial" | "failed" | "interrupted";
52
56
  createdAt: number;
@@ -109,6 +113,7 @@ interface InternalChild {
109
113
  result?: ChildResult;
110
114
  startedAt: number;
111
115
  endedAt?: number;
116
+ turn: number;
112
117
  handle?: ChildHandle;
113
118
  shouldStart?: () => boolean;
114
119
  }
@@ -157,6 +162,10 @@ class FailedChildHandle implements ChildHandle {
157
162
  return Promise.reject(this.dead());
158
163
  }
159
164
 
165
+ extend(): never {
166
+ throw this.dead();
167
+ }
168
+
160
169
  interrupt(): Promise<void> {
161
170
  return Promise.resolve();
162
171
  }
@@ -303,6 +312,7 @@ export class SubagentRegistry implements RunRegistry {
303
312
  agent: info.agent,
304
313
  status: "pending",
305
314
  startedAt: this.now(),
315
+ turn: 1,
306
316
  };
307
317
  run.children.push(child);
308
318
  this.children.set(child.childId, child);
@@ -360,7 +370,7 @@ export class SubagentRegistry implements RunRegistry {
360
370
  * and wires result settlement for resumed generations. Returns the slot
361
371
  * releaser.
362
372
  */
363
- async admitChild(childId: string): Promise<() => void> {
373
+ async admitChild(childId: string, ticket?: { current: () => boolean }): Promise<() => void> {
364
374
  await this.acquireSlot();
365
375
  let released = false;
366
376
  const release = () => {
@@ -377,6 +387,15 @@ export class SubagentRegistry implements RunRegistry {
377
387
  release();
378
388
  throw new ChildCancelledError();
379
389
  }
390
+ // The generation that queued this request settled while it waited
391
+ // (interrupt/dispose; maybe a newer resume is queued behind other
392
+ // children): hand the slot on, without touching the child's status.
393
+ // Without a ticket, fall back to the handle's status.
394
+ const stale = ticket ? !ticket.current() : (child.handle?.status() ?? "pending") !== "pending";
395
+ if (stale) {
396
+ release();
397
+ throw new ChildCancelledError("settled while queued");
398
+ }
380
399
  this.transitionChild(child, "running");
381
400
  const handle = child.handle;
382
401
  if (handle) {
@@ -387,6 +406,64 @@ export class SubagentRegistry implements RunRegistry {
387
406
  return release;
388
407
  }
389
408
 
409
+ /**
410
+ * Resume an ended child: a new user turn on its session. Returns once the
411
+ * request is accepted; admission runs in the background (design.md §4.6
412
+ * "Child lifecycle", resume rows). Until a slot is granted the child is
413
+ * pending. `queuedBehind` is null when a slot was free at request time,
414
+ * otherwise how many children were already waiting for one.
415
+ */
416
+ async resumeChild(
417
+ childId: string,
418
+ message: string,
419
+ opts: { timeoutMs?: number } = {},
420
+ ): Promise<{ queuedBehind: number | null }> {
421
+ const child = this.children.get(childId);
422
+ if (!child) throw new Error(`unknown child ${childId}`);
423
+ const handle = child.handle;
424
+ if (!handle) throw new Error(`subagent ${childId} has no live session to resume`);
425
+ const queuedBehind = this.activeSlots >= this.maxChildren ? this.slotWaiters.length : null;
426
+ const previous = {
427
+ status: child.status,
428
+ result: child.result,
429
+ startedAt: child.startedAt,
430
+ endedAt: child.endedAt,
431
+ };
432
+ const accepted = handle.resume(message, opts);
433
+ // Mark the child queued before awaiting: with a free slot, admitChild's
434
+ // continuation can run first and move it to running, which must win.
435
+ child.status = "pending";
436
+ child.result = undefined;
437
+ child.endedAt = undefined;
438
+ child.startedAt = this.now();
439
+ child.turn += 1;
440
+ try {
441
+ await accepted;
442
+ } catch (err) {
443
+ // Undo this attempt's +1 even if a concurrent accepted resume has
444
+ // already moved the child to running (the pending guard would skip).
445
+ child.turn -= 1;
446
+ if (child.status === "pending") {
447
+ child.status = previous.status;
448
+ child.result = previous.result;
449
+ child.startedAt = previous.startedAt;
450
+ child.endedAt = previous.endedAt;
451
+ }
452
+ throw err;
453
+ }
454
+ // Wired at request time, not admission: a turn that never gets a slot
455
+ // (interrupt/dispose while queued, abort drain bound) still settles here.
456
+ handle.result.then((result) => this.settleChild(childId, result));
457
+ if (child.status === "pending") {
458
+ const run = this.runs.get(child.runId);
459
+ if (run) {
460
+ this.recomputeRunStatus(run);
461
+ this.emit(run);
462
+ }
463
+ }
464
+ return { queuedBehind };
465
+ }
466
+
390
467
  /** Current generation's result promise for a child (post-resume aware). */
391
468
  getResult(childId: string): Promise<ChildResult> | undefined {
392
469
  return this.children.get(childId)?.handle?.result;
@@ -570,6 +647,7 @@ function snapshot(run: InternalRun): RunRecord {
570
647
  result: c.result,
571
648
  startedAt: c.startedAt,
572
649
  endedAt: c.endedAt,
650
+ turn: c.turn,
573
651
  })),
574
652
  };
575
653
  }