@mjasnikovs/pi-task 0.39.0 → 0.39.2

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.
Files changed (50) hide show
  1. package/README.md +2 -2
  2. package/dist/config/group-args.d.ts +24 -9
  3. package/dist/config/group-args.js +38 -28
  4. package/dist/config/register.d.ts +0 -9
  5. package/dist/config/register.js +17 -62
  6. package/dist/shared/child-process.d.ts +34 -32
  7. package/dist/shared/child-process.js +44 -58
  8. package/dist/shared/command-watchdog.d.ts +12 -4
  9. package/dist/shared/command-watchdog.js +6 -7
  10. package/dist/shared/connection-error.d.ts +7 -0
  11. package/dist/shared/connection-error.js +65 -0
  12. package/dist/shared/model-endpoint.d.ts +12 -24
  13. package/dist/shared/model-endpoint.js +32 -82
  14. package/dist/shared/model-resolve.d.ts +105 -0
  15. package/dist/shared/model-resolve.js +97 -0
  16. package/dist/shared/reasoning-capability.d.ts +20 -0
  17. package/dist/shared/reasoning-capability.js +32 -1
  18. package/dist/shared/stall-probe.d.ts +51 -0
  19. package/dist/shared/stall-probe.js +79 -0
  20. package/dist/task/child-runner.d.ts +76 -278
  21. package/dist/task/child-runner.js +186 -722
  22. package/dist/task/context-usage.js +2 -7
  23. package/dist/task/failure-classifier.js +53 -81
  24. package/dist/task/gate-child.js +1 -1
  25. package/dist/task/impl-widget.d.ts +2 -0
  26. package/dist/task/impl-widget.js +4 -0
  27. package/dist/task/implementation-hold.d.ts +11 -0
  28. package/dist/task/implementation-hold.js +20 -0
  29. package/dist/task/implementation-scope.d.ts +24 -0
  30. package/dist/task/implementation-scope.js +34 -0
  31. package/dist/task/loop-detector.d.ts +13 -5
  32. package/dist/task/loop-detector.js +11 -5
  33. package/dist/task/model-hold-stash.js +4 -14
  34. package/dist/task/orchestrator.d.ts +1 -8
  35. package/dist/task/orchestrator.js +11 -34
  36. package/dist/task/phases.js +2 -2
  37. package/dist/task/stall-detector.d.ts +1 -1
  38. package/dist/task/stall-detector.js +1 -1
  39. package/dist/workers/model-warning.d.ts +4 -16
  40. package/dist/workers/model-warning.js +14 -70
  41. package/dist/workers/pi-worker-core.d.ts +65 -20
  42. package/dist/workers/pi-worker-core.js +109 -50
  43. package/dist/workers/reasoning-warning.js +2 -24
  44. package/dist/workers/worker-failure.d.ts +2 -0
  45. package/dist/workers/worker-failure.js +2 -1
  46. package/dist/workers/worker-kill.d.ts +30 -11
  47. package/dist/workers/worker-kill.js +68 -20
  48. package/dist/workers/worker-profiles.d.ts +20 -0
  49. package/dist/workers/worker-profiles.js +22 -9
  50. package/package.json +1 -1
@@ -1,26 +1,15 @@
1
1
  import { getConfig } from '../config/config.js';
2
- import { setGroupWindows, setUnusableSpecs } from '../config/group-args.js';
3
- import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
2
+ import { setGroupModels, setModelEndpoints } from '../config/group-args.js';
4
3
  import { CHILD_GROUPS } from '../config/groups.js';
5
- import { contextWindowForSpec } from '../task/context-usage.js';
4
+ import { resolveGroupModels, resolveModelEndpoints } from '../shared/model-resolve.js';
6
5
  import { registerSessionHint } from './session-hint.js';
7
6
  const WIDGET_KEY = 'pi-task-model-warning';
8
- export function findModelProblems(lookup, specs) {
9
- const out = [];
10
- for (const group of CHILD_GROUPS) {
11
- const spec = specs[group];
12
- if (spec === MODEL_INHERIT)
13
- continue;
14
- const parts = splitSpec(spec);
15
- if (!parts || lookup.find(parts.provider, parts.id) === undefined) {
16
- out.push({ group, spec, why: 'unresolved' });
17
- continue;
18
- }
19
- if (lookup.extensionProviders.has(parts.provider)) {
20
- out.push({ group, spec, why: 'extension' });
21
- }
22
- }
23
- return out;
7
+ /** The cells worth a line, in group order. */
8
+ export function modelProblems(snapshot) {
9
+ return CHILD_GROUPS.flatMap(group => {
10
+ const { spec, problem } = snapshot[group];
11
+ return problem === undefined ? [] : [{ group, spec, why: problem }];
12
+ });
24
13
  }
25
14
  /**
26
15
  * The hint line, or null when every cell is fine.
@@ -54,60 +43,15 @@ export function registerModelWarning(pi,
54
43
  readSpecs = () => getConfig().groupModels) {
55
44
  // TWO handlers, deliberately. The resolution pass must run in EVERY mode:
56
45
  // `registerSessionHint` returns early when `ctx.mode !== 'tui'`, so folding
57
- // this into it would leave the argv drop and the churn windows disarmed for
58
- // every headless and `--print` run — a guard that only works when someone is
59
- // watching is not a guard.
46
+ // this into it would leave the argv drop, the churn windows and the
47
+ // dead-backend probe disarmed for every headless and `--print` run — a
48
+ // guard that only works when someone is watching is not a guard.
60
49
  pi.on('session_start', (_event, ctx) => {
61
- resolveModelCells(ctx, readSpecs());
50
+ setGroupModels(resolveGroupModels(ctx, readSpecs()));
51
+ setModelEndpoints(resolveModelEndpoints(ctx));
62
52
  });
63
53
  registerSessionHint(pi, WIDGET_KEY, ctx => {
64
- const text = formatModelWarning(findModelProblems(lookupFor(ctx), readSpecs()));
54
+ const text = formatModelWarning(modelProblems(resolveGroupModels(ctx, readSpecs())));
65
55
  return text === null ? null : { text };
66
56
  });
67
57
  }
68
- /**
69
- * Answer, once, what every model cell resolves to — and leave both answers where
70
- * the code that has no `ctx` can read them.
71
- *
72
- * `unresolved` only feeds the argv drop: an extension-provided model may well
73
- * work, since children load explicitly whitelisted extensions, and dropping its
74
- * flag would break a config that is merely fragile.
75
- */
76
- export function resolveModelCells(ctx, specs) {
77
- const problems = findModelProblems(lookupFor(ctx), specs);
78
- setUnusableSpecs(problems.filter(p => p.why === 'unresolved').map(p => p.spec));
79
- // The SAME walk fills the window table, so the argv and the churn rule can
80
- // never disagree about which model a group runs on.
81
- //
82
- // An `inherit` cell gets NO entry, not the parent's window. Storing one
83
- // would freeze a session_start snapshot in front of the live per-run value,
84
- // so a user who switches the session model with Ctrl+P to a bigger one would
85
- // have every child judged against the old window — the churn rule then fires
86
- // early and kills a healthy child.
87
- //
88
- // The whole walk is guarded because `ctx.model` and `ctx.modelRegistry` are
89
- // GETTERS that call `assertActive()` and throw on a stale context. Losing the
90
- // windows must not also lose the `setUnusableSpecs` above it, nor the hint.
91
- try {
92
- setGroupWindows(Object.fromEntries(CHILD_GROUPS.filter(g => specs[g] !== MODEL_INHERIT).map(g => [g, contextWindowForSpec(ctx, specs[g])])));
93
- }
94
- catch {
95
- setGroupWindows({});
96
- }
97
- return problems;
98
- }
99
- function lookupFor(ctx) {
100
- try {
101
- const registry = ctx.modelRegistry;
102
- return {
103
- find: (provider, id) => registry.find(provider, id),
104
- extensionProviders: new Set(registry.getRegisteredProviderIds())
105
- };
106
- }
107
- catch {
108
- // A registry that cannot answer must not condemn every cell. Claiming
109
- // every spec is unresolved would drop every --model flag on a session
110
- // whose runtime simply was not ready.
111
- return { find: () => ({}), extensionProviders: new Set() };
112
- }
113
- }
@@ -3,6 +3,29 @@ import { type CommandKill } from '../shared/command-watchdog.js';
3
3
  export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
4
4
  import { RESTART_ORDER } from './worker-kill.js';
5
5
  import { type WorkerGuardOverride, type WorkerGuardPolicy, type WorkerPolicyInputs, type WorkerProfileId } from './worker-profiles.js';
6
+ /**
7
+ * The argv of one model child.
8
+ *
9
+ * `--mode json` puts the child into the structured event stream the runner
10
+ * parses. Without it the child emits plain text, every line fails JSON.parse,
11
+ * finalText stays empty, and every caller fails with "produced no output". A
12
+ * refactor has dropped it once already; do not remove it again.
13
+ *
14
+ * An empty `tools` string means "no tools at all" — `--no-tools`, never
15
+ * `--tools ''`, which pi rejects. A no-tools child cannot make a tool call, so
16
+ * it carries no in-run guard extension either: the guards all hang off pi's
17
+ * `tool_call` hook.
18
+ *
19
+ * The prompt is NOT an argv element: it goes over stdin (getPiInvocation), so a
20
+ * large prompt cannot exceed the OS argv ceiling, which fails the spawn outright
21
+ * rather than truncating (`E2BIG` on this platform).
22
+ *
23
+ * `groupArgs` is the child's group fragment, `--model` then `--thinking`, either
24
+ * half possibly absent. Resolved by the CALLER: both are properties of the
25
+ * child's ROLE, and this function is handed tools, not a name. One field rather
26
+ * than a `model` beside a `thinking` so nothing composes the halves by hand.
27
+ */
28
+ export declare function childArgs(tools: string, extensions?: readonly string[], groupArgs?: readonly string[]): string[];
6
29
  /**
7
30
  * Tool calls that can GROUND an APIS claim — i.e. return content a signature or
8
31
  * command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
@@ -66,10 +89,29 @@ export interface RunWorkerInput {
66
89
  cwd: string;
67
90
  signal?: AbortSignal;
68
91
  spawn?: SpawnFn;
69
- /** Comma-separated tool whitelist passed to `pi --tools`. Defaults to read,grep,find,ls. */
92
+ /**
93
+ * Comma-separated tool whitelist passed to `pi --tools`. Defaults to
94
+ * read,grep,find,ls; `''` means `--no-tools` (see childArgs).
95
+ */
70
96
  tools?: string;
71
97
  /** Internal extension entry-point paths to load via `-e <path>` (see childBaseArgs). */
72
- extensions?: string[];
98
+ extensions?: readonly string[];
99
+ /**
100
+ * ONE more attempt after the loop budget is spent, with different tools and
101
+ * a terminal hint, instead of returning the loop kill. It is not a retry:
102
+ * no restart rule runs on it, and its result is the run's result.
103
+ *
104
+ * For a child whose deliverable is a text rewrite that never strictly
105
+ * needed a read (refine): a model that thrashed re-reading files is stripped
106
+ * of its tools and ordered to emit from what it has, because a hard fail
107
+ * there kills a whole /task-auto run for a model that merely over-explored.
108
+ * A child whose output depends on real reads must NOT carry one — with no
109
+ * tools it would fabricate.
110
+ */
111
+ rescue?: {
112
+ tools: string;
113
+ hint: (hit: LoopHit) => string;
114
+ };
73
115
  /** Called for each tool execution start and text-writing event inside the worker. */
74
116
  onLine?: (line: string) => void;
75
117
  /** Called when a tool call FINISHES, with its (truncatable) result — lets a caller
@@ -197,6 +239,13 @@ export interface WorkerRestart {
197
239
  workMs: number;
198
240
  /** Reason-specific diagnosis: the looping call, the hung tool, the error text. */
199
241
  detail?: string;
242
+ /** The hit itself on a `loop` restart, for a consumer that records the call. */
243
+ loopHit?: LoopHit;
244
+ /**
245
+ * This attempt was discarded for the RESCUE, not a retry: the loop budget
246
+ * was spent, and what follows runs under `rescue`'s tools and hint.
247
+ */
248
+ rescue?: true;
200
249
  /**
201
250
  * Characters of ANSWER TEXT this attempt had produced at the moment it was
202
251
  * thrown away.
@@ -324,10 +373,13 @@ export interface RunWorkerResult {
324
373
  *
325
374
  * Check BEFORE `aborted`, same reasoning as `stalled`: the kill aborts too.
326
375
  */
327
- commandTimedOut?: {
328
- toolName: string;
329
- timeoutMs: number;
330
- };
376
+ commandTimedOut?: CommandKill;
377
+ /**
378
+ * The final attempt was the `rescue`. Its text is the run's answer; a
379
+ * rescue that produced none is honestly the loop kill it stood in for, and
380
+ * the caller reports it as one.
381
+ */
382
+ rescued?: true;
331
383
  /**
332
384
  * Set when the stream watchdog killed the worker's FINAL attempt: the model
333
385
  * stream produced nothing for the configured window while no tool was running.
@@ -350,9 +402,16 @@ interface RestartState {
350
402
  streamStalled?: {
351
403
  idleMs: number;
352
404
  };
405
+ stalled: boolean;
406
+ /** The profile's `stalled.restart` switch. */
407
+ restartOnStalled: boolean;
353
408
  timedOut: boolean;
354
409
  modelError?: string;
355
410
  leaked: string | null;
411
+ /** A clean, complete run that answered with no text at all. */
412
+ empty: boolean;
413
+ /** The profile's `empty-answer` switch. */
414
+ restartOnEmpty: boolean;
356
415
  /** The cap this attempt actually died against, not the configured one:
357
416
  * `extend`/`progress` can push the deadline out during the attempt. */
358
417
  effectiveCapMs: number;
@@ -402,19 +461,5 @@ interface RestartRule {
402
461
  /** Backoff before re-spawning, in ms. Only the connection rule waits. */
403
462
  backoffMs?: (s: RestartState) => number;
404
463
  }
405
- /**
406
- * The restart ladder, in precedence order. FIRST MATCH WINS.
407
- *
408
- * Read the `!loopHit` guards as "a loop kill outranks me even when it has no
409
- * budget left". They are not redundant with row order: when a loop is detected
410
- * but the shared budget is spent, row 1 declines, and without those guards row 2
411
- * or 4 would then restart the same runaway child under a hint that does not
412
- * describe why it died.
413
- *
414
- * The whole ritual — check the budget, set the hint, spend the counters, record
415
- * and announce the discarded attempt, sleep, re-spawn — belongs to the loop in
416
- * `runWorker`, so a new failure mode is one row here and cannot be added without
417
- * becoming visible in `restarts`.
418
- */
419
464
  export declare const RESTART_RULES: readonly RestartRule[];
420
465
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -4,9 +4,9 @@ import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../s
4
4
  export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
5
5
  import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
6
6
  import { childBaseArgs } from '../shared/child-extensions.js';
7
- import { LoopDetector, MAX_LOOP_RESTARTS } from '../task/loop-detector.js';
7
+ import { LoopDetector, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/loop-detector.js';
8
8
  import { StallDetector, formatStallHint } from '../task/stall-detector.js';
9
- import { formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
9
+ import { isConnectionError, connectionRetryBackoffMs } from '../shared/connection-error.js';
10
10
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
11
11
  import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
12
12
  import { modelSpecFromArgs } from '../config/group-models.js';
@@ -16,6 +16,33 @@ import { CARRY_FORWARD_IDS, RESTART_ORDER } from './worker-kill.js';
16
16
  import { applyOverride, WORKER_PROFILES } from './worker-profiles.js';
17
17
  /** The tool whitelist a caller gets when it names none. */
18
18
  const DEFAULT_TOOLS = 'read,grep,find,ls';
19
+ /**
20
+ * The argv of one model child.
21
+ *
22
+ * `--mode json` puts the child into the structured event stream the runner
23
+ * parses. Without it the child emits plain text, every line fails JSON.parse,
24
+ * finalText stays empty, and every caller fails with "produced no output". A
25
+ * refactor has dropped it once already; do not remove it again.
26
+ *
27
+ * An empty `tools` string means "no tools at all" — `--no-tools`, never
28
+ * `--tools ''`, which pi rejects. A no-tools child cannot make a tool call, so
29
+ * it carries no in-run guard extension either: the guards all hang off pi's
30
+ * `tool_call` hook.
31
+ *
32
+ * The prompt is NOT an argv element: it goes over stdin (getPiInvocation), so a
33
+ * large prompt cannot exceed the OS argv ceiling, which fails the spawn outright
34
+ * rather than truncating (`E2BIG` on this platform).
35
+ *
36
+ * `groupArgs` is the child's group fragment, `--model` then `--thinking`, either
37
+ * half possibly absent. Resolved by the CALLER: both are properties of the
38
+ * child's ROLE, and this function is handed tools, not a name. One field rather
39
+ * than a `model` beside a `thinking` so nothing composes the halves by hand.
40
+ */
41
+ export function childArgs(tools, extensions = [], groupArgs = []) {
42
+ const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
43
+ const internal = tools === '' ? [] : extensions;
44
+ return [...childBaseArgs(internal), ...groupArgs, '--mode', 'json', ...toolFlags];
45
+ }
19
46
  /**
20
47
  * The one place `'unknown'` becomes the 0 both consumers already treat as
21
48
  * "no window". Written once so a future reader cannot re-introduce the optional
@@ -232,21 +259,22 @@ absoluteCeilingMs) {
232
259
  * `runWorker`, so a new failure mode is one row here and cannot be added without
233
260
  * becoming visible in `restarts`.
234
261
  */
262
+ /**
263
+ * A stall hit carries no meaningful windowSize (rule 1 sets it to 0), so
264
+ * printing the loop shape would misname why the attempt died.
265
+ */
266
+ function loopDetail(hit) {
267
+ return hit.stall ?
268
+ `${hit.call.name} ${hit.stall} ×${hit.count}`
269
+ : `${hit.call.name} ×${hit.count}/${hit.windowSize}`;
270
+ }
235
271
  export const RESTART_RULES = [
236
272
  {
237
- // A loop-kill gets the same restart-with-hint treatment every other phase
238
- // already gets (runPhaseChild) — name the offending call so the
273
+ // A loop-kill is restarted with a hint naming the offending call so the
239
274
  // re-spawn avoids it. Bounded by the shared restart budget.
240
275
  reason: 'loop',
241
276
  detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
242
- {
243
- // A stall hit carries no meaningful windowSize (rule 1 sets
244
- // it to 0), so printing the loop shape would misname why the
245
- // attempt died.
246
- detail: s.loopHit.stall ?
247
- `${s.loopHit.call.name} ${s.loopHit.stall} ×${s.loopHit.count}`
248
- : `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}`
249
- }
277
+ { detail: loopDetail(s.loopHit) }
250
278
  : null,
251
279
  hint: s => s.loopHit.stall ? formatStallHint(s.loopHit.stall) : formatLoopHint(s.loopHit),
252
280
  counters: { shared: true }
@@ -284,6 +312,19 @@ export const RESTART_RULES = [
284
312
  hint: s => streamStallHint(s.streamStalled.idleMs),
285
313
  counters: { shared: true }
286
314
  },
315
+ {
316
+ // A dead-backend kill, for the profiles that would rather earn the
317
+ // verdict on every attempt than trust one probe sample. No hint: nothing
318
+ // the model did caused it, and a hint in flight must survive the retry.
319
+ reason: 'stalled',
320
+ detect: s => (s.stalled
321
+ && !s.loopHit
322
+ && s.restartOnStalled
323
+ && s.restartBudgetSpent < MAX_LOOP_RESTARTS) ?
324
+ { detail: 'no output for the stall window and no endpoint answered' }
325
+ : null,
326
+ counters: { shared: true }
327
+ },
287
328
  {
288
329
  // A wall-clock timeout (the backstop for varied thrash the exact-match
289
330
  // detector misses) is also restartable, sharing the same budget. Skip when
@@ -332,25 +373,22 @@ export const RESTART_RULES = [
332
373
  : null,
333
374
  hint: s => leakedToolCallHint(s.leaked),
334
375
  counters: { leak: true }
376
+ },
377
+ {
378
+ // An empty completion on a clean run, for the profiles that treat it as
379
+ // a swallowed provider error rather than an answer. No hint: there is
380
+ // nothing to correct, and one already in flight must survive.
381
+ reason: 'empty-answer',
382
+ detect: s => s.empty && !s.loopHit && s.restartOnEmpty && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
383
+ { detail: 'no assistant text' }
384
+ : null,
385
+ counters: { shared: true }
335
386
  }
336
387
  ];
337
388
  export async function runWorker(input) {
338
- const tools = input.tools ?? DEFAULT_TOOLS;
339
- // `--mode json` makes pi emit structured events as they happen instead of
340
- // buffering the assistant text and flushing on exit. Its print-mode source
341
- // shows both halves: under `json` a session subscriber writes every event to
342
- // stdout as it arrives, while under `text` NOTHING is written until after the
343
- // prompt resolves, when the last assistant message is printed once. That is
344
- // what makes the wait/work split real — onFirstByte would otherwise fire
345
- // moments before close and leave workMs at nearly zero.
346
- const baseArgs = [
347
- ...childBaseArgs(input.extensions ?? []),
348
- ...(input.groupArgs ?? []),
349
- '--mode',
350
- 'json',
351
- '--tools',
352
- tools
353
- ];
389
+ // Reassigned once at most, by the rescue.
390
+ let tools = input.tools ?? DEFAULT_TOOLS;
391
+ let rescued = false;
354
392
  // ONE resolution, before the first attempt. Every guard read below goes
355
393
  // through `policy`, so "which knobs is this child running" has exactly one
356
394
  // answer and it is observable (`onPolicy`) rather than inferable.
@@ -404,7 +442,11 @@ export async function runWorker(input) {
404
442
  const prompt = [hint, carried, input.prompt]
405
443
  .filter((p) => p !== null)
406
444
  .join('\n\n');
407
- const invocation = getPiInvocation([...baseArgs], prompt);
445
+ // `--mode json` (childArgs) makes pi emit events as they happen instead
446
+ // of buffering the assistant text and flushing on exit, which is what
447
+ // makes the wait/work split below real — onFirstByte would otherwise
448
+ // fire moments before close and leave workMs at nearly zero.
449
+ const invocation = getPiInvocation(childArgs(tools, input.extensions ?? [], input.groupArgs ?? []), prompt);
408
450
  const tAttemptStart = Date.now();
409
451
  let tFirstByte = null;
410
452
  // loop === false turns the guard off entirely (detector is null and no
@@ -423,11 +465,6 @@ export async function runWorker(input) {
423
465
  // context event at all, so waiting for one leaves the rule permanently
424
466
  // disarmed. The parent knows the window at spawn time.
425
467
  stallDetector?.noteContext(contextWindowTokens(input.contextWindow));
426
- // Capture the hit the detector reports (it also returns it to the unified
427
- // runner, which kills the child on a hit). Without capturing it here the
428
- // SIGTERM that kill produces would surface as a bare non-zero exit the
429
- // caller couldn't distinguish from a crash.
430
- let loopHit;
431
468
  // Reset EACH attempt: on a restart the previous attempt's calls are
432
469
  // discarded with its text, so the count must describe only the attempt
433
470
  // whose text this call returns.
@@ -472,11 +509,9 @@ export async function runWorker(input) {
472
509
  // Loop detector first: it names the offending call and its
473
510
  // hint is the more specific one. The stall detector is the
474
511
  // backstop for the thrash shapes a 20-call argument window
475
- // cannot see.
476
- const hit = loopDetector?.record(call) ?? stallDetector?.record(call) ?? null;
477
- if (hit && !loopHit)
478
- loopHit = hit;
479
- return hit;
512
+ // cannot see. A hit is returned to the runner, which kills
513
+ // the child and reports it as `kill.by === 'loop'`.
514
+ return loopDetector?.record(call) ?? stallDetector?.record(call) ?? null;
480
515
  },
481
516
  // Output is the other half of "still working": a worker
482
517
  // writing its answer is making progress even when it has no
@@ -517,7 +552,7 @@ export async function runWorker(input) {
517
552
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
518
553
  // Record + announce a discarded attempt. Called from every `continue`
519
554
  // branch below, so a restart cannot be added without becoming visible.
520
- const noteRestart = (reason, detail) => {
555
+ const noteRestart = (reason, detail, extra = {}) => {
521
556
  const record = {
522
557
  attempt: restarts.length + 1,
523
558
  reason,
@@ -525,7 +560,8 @@ export async function runWorker(input) {
525
560
  waitMs,
526
561
  workMs,
527
562
  partialChars: text.trim().length,
528
- ...(detail ? { detail } : {})
563
+ ...(detail ? { detail } : {}),
564
+ ...extra
529
565
  };
530
566
  restarts.push(record);
531
567
  input.onRestart?.(record);
@@ -545,22 +581,33 @@ export async function runWorker(input) {
545
581
  };
546
582
  const text = result.text ?? '';
547
583
  const timedOut = timeout.timedOut();
548
- const commandKill = cmdWatch?.killed();
549
- const streamStalled = result.streamStalled;
584
+ const kill = result.kill;
585
+ const loopHit = kill?.by === 'loop' ? kill.hit : undefined;
586
+ const commandKill = kill?.by === 'command-timeout' ? kill : undefined;
587
+ const streamStalled = kill?.by === 'stream-stall' ? { idleMs: kill.idleMs } : undefined;
588
+ const stalled = kill?.by === 'stalled';
550
589
  // Only treat output as a leak on a clean, complete run — a non-zero exit
551
590
  // or abort yields partial text the caller already handles, and detecting
552
591
  // there would just mislabel the real failure.
553
- const leaked = result.exitCode === 0 && !result.aborted ? detectLeakedToolCall(text) : null;
592
+ const clean = result.exitCode === 0 && !result.aborted;
593
+ const leaked = clean ? detectLeakedToolCall(text) : null;
554
594
  // THE RESTART LADDER. Precedence is RESTART_RULES' row order; this loop
555
595
  // owns the ritual every rule would otherwise repeat: budget, hint, counters,
556
596
  // record-and-announce, backoff, re-spawn.
597
+ //
598
+ // Not run on the rescue attempt, which is final by contract, nor after a
599
+ // cancel: a user who pressed ESC between attempts must not buy a spawn.
557
600
  const state = {
558
601
  ...(loopHit ? { loopHit } : {}),
559
602
  ...(commandKill ? { commandKill } : {}),
560
603
  ...(streamStalled ? { streamStalled } : {}),
604
+ stalled,
605
+ restartOnStalled: guards.stalled !== false && guards.stalled.restart,
561
606
  timedOut,
562
607
  ...(result.modelError !== undefined ? { modelError: result.modelError } : {}),
563
608
  leaked,
609
+ empty: clean && result.modelError === undefined && text.trim().length === 0,
610
+ restartOnEmpty: guards['empty-answer'],
564
611
  effectiveCapMs,
565
612
  tools,
566
613
  restartBudgetSpent,
@@ -568,8 +615,9 @@ export async function runWorker(input) {
568
615
  connectionRetries: guards['connection-error'],
569
616
  leakRetries
570
617
  };
618
+ const ladderOpen = !rescued && input.signal?.aborted !== true;
571
619
  let restarted = false;
572
- for (const rule of RESTART_RULES) {
620
+ for (const rule of ladderOpen ? RESTART_RULES : []) {
573
621
  const hit = rule.detect(state);
574
622
  if (!hit)
575
623
  continue;
@@ -585,7 +633,7 @@ export async function runWorker(input) {
585
633
  connRetries++;
586
634
  // Noted BEFORE any backoff sleep, so the record's wallMs stays the
587
635
  // attempt's own clock; the sleep lands in totalWallMs, where it belongs.
588
- noteRestart(rule.reason, hit.detail);
636
+ noteRestart(rule.reason, hit.detail, loopHit ? { loopHit } : {});
589
637
  if (rule.backoffMs)
590
638
  await (input.sleepFor ?? defaultSleep)(rule.backoffMs(state));
591
639
  restarted = true;
@@ -593,6 +641,15 @@ export async function runWorker(input) {
593
641
  }
594
642
  if (restarted)
595
643
  continue;
644
+ // Reached only with the loop rule out of budget: the rules above all
645
+ // decline a loop-killed attempt once the shared budget is spent.
646
+ if (ladderOpen && loopHit && input.rescue) {
647
+ rescued = true;
648
+ hint = input.rescue.hint(loopHit);
649
+ tools = input.rescue.tools;
650
+ noteRestart('loop', loopDetail(loopHit), { loopHit, rescue: true });
651
+ continue;
652
+ }
596
653
  // SALVAGE. Returning the LAST attempt's text unconditionally makes a
597
654
  // worker whose final attempt was killed early report nothing at all — even
598
655
  // when a discarded attempt produced a usable answer that was still in hand
@@ -617,7 +674,7 @@ export async function runWorker(input) {
617
674
  exitCode: result.exitCode,
618
675
  aborted: result.aborted,
619
676
  timedOut,
620
- ...(result.stalled === true ? { stalled: true } : {}),
677
+ ...(stalled ? { stalled: true } : {}),
621
678
  ...(loopHit ? { loopHit } : {}),
622
679
  ...(leaked ? { leakedToolCall: leaked } : {}),
623
680
  ...(commandKill ? { commandTimedOut: commandKill } : {}),
@@ -646,16 +703,18 @@ export async function runWorker(input) {
646
703
  ...(leaked ? { leakedToolCall: leaked } : {}),
647
704
  ...(loopHit ? { loopHit } : {}),
648
705
  ...(timedOut ? { timedOut: true } : {}),
649
- ...(result.stalled ? { stalled: true } : {}),
706
+ ...(stalled ? { stalled: true } : {}),
650
707
  ...(streamStalled ? { streamStalled } : {}),
651
708
  ...(commandKill ?
652
709
  {
653
710
  commandTimedOut: {
654
711
  toolName: commandKill.toolName,
655
- timeoutMs: commandKill.timeoutMs
712
+ timeoutMs: commandKill.timeoutMs,
713
+ ...(commandKill.detail ? { detail: commandKill.detail } : {})
656
714
  }
657
715
  }
658
- : {})
716
+ : {}),
717
+ ...(rescued ? { rescued: true } : {})
659
718
  };
660
719
  }
661
720
  }
@@ -22,7 +22,7 @@
22
22
  import { getConfig } from '../config/config.js';
23
23
  import { effectiveReasoning } from '../config/reasoning.js';
24
24
  import { reasoningMismatches } from '../shared/reasoning-capability.js';
25
- import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
25
+ import { resolveModel } from '../shared/model-resolve.js';
26
26
  import { probeChatTemplateCaps } from '../shared/model-endpoint.js';
27
27
  import { registerSessionHint } from './session-hint.js';
28
28
  const WIDGET_KEY = 'pi-task-reasoning-warning';
@@ -93,7 +93,7 @@ probe = probeChatTemplateCaps,
93
93
  */
94
94
  readSpecs = () => getConfig().groupModels) {
95
95
  registerSessionHint(pi, WIDGET_KEY, ctx => {
96
- const facts = (g) => groupModelFacts(ctx, readSpecs()[g]);
96
+ const facts = (g) => resolveModel(ctx, readSpecs()[g]);
97
97
  const mismatches = reasoningMismatches(facts, readSettings());
98
98
  const base = formatReasoningWarning(mismatches);
99
99
  if (base === null)
@@ -123,28 +123,6 @@ readSpecs = () => getConfig().groupModels) {
123
123
  };
124
124
  });
125
125
  }
126
- /** What one group runs on, as {@link reasoningMismatches} wants it. */
127
- function groupModelFacts(ctx, spec) {
128
- // `inherit` is the session's model. That is decision 3 of the model table —
129
- // children are NOT switched to follow the host — and the honest value is
130
- // settings.json's default, which need not be the session's. Naming the
131
- // session's model is still the better of the two: it is the one the user can
132
- // see, and on every machine with one provider the two agree.
133
- const model = spec === MODEL_INHERIT ?
134
- ctx.model
135
- : (() => {
136
- const parts = splitSpec(spec);
137
- return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
138
- })();
139
- if (!model)
140
- return undefined;
141
- return {
142
- name: model.name || model.id,
143
- reasoning: model.reasoning,
144
- ...(model.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: model.thinkingLevelMap }),
145
- ...(model.baseUrl ? { baseUrl: model.baseUrl } : {})
146
- };
147
- }
148
126
  /** The distinct servers behind a set of mismatches, deduped by URL. */
149
127
  function distinctBackends(mismatches, facts) {
150
128
  const byUrl = new Map();
@@ -44,6 +44,7 @@ export interface WorkerFailureInput {
44
44
  commandTimedOut?: {
45
45
  toolName: string;
46
46
  timeoutMs: number;
47
+ detail?: string;
47
48
  };
48
49
  streamStalled?: {
49
50
  idleMs: number;
@@ -64,6 +65,7 @@ export type WorkerFailure = {
64
65
  kind: 'command-timeout';
65
66
  toolName: string;
66
67
  timeoutMs: number;
68
+ detail?: string;
67
69
  } | {
68
70
  kind: 'stream-stall';
69
71
  idleMs: number;
@@ -59,7 +59,8 @@ export const FAILURE_RULES = [
59
59
  {
60
60
  kind: 'command-timeout',
61
61
  toolName: r.commandTimedOut.toolName,
62
- timeoutMs: r.commandTimedOut.timeoutMs
62
+ timeoutMs: r.commandTimedOut.timeoutMs,
63
+ ...(r.commandTimedOut.detail ? { detail: r.commandTimedOut.detail } : {})
63
64
  }
64
65
  : null
65
66
  },