@mjasnikovs/pi-task 0.38.30 → 0.38.32

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 (73) hide show
  1. package/README.md +2 -1
  2. package/dist/config/config.d.ts +16 -2
  3. package/dist/config/config.js +7 -2
  4. package/dist/config/group-args.d.ts +52 -0
  5. package/dist/config/group-args.js +110 -0
  6. package/dist/config/group-models.d.ts +88 -0
  7. package/dist/config/group-models.js +117 -0
  8. package/dist/config/groups.d.ts +76 -0
  9. package/dist/config/groups.js +110 -0
  10. package/dist/config/option-picker.d.ts +42 -0
  11. package/dist/config/option-picker.js +73 -0
  12. package/dist/config/reasoning.d.ts +22 -63
  13. package/dist/config/reasoning.js +37 -108
  14. package/dist/config/register.d.ts +98 -12
  15. package/dist/config/register.js +228 -23
  16. package/dist/index.js +4 -0
  17. package/dist/remote/push.js +1 -7
  18. package/dist/shared/command-watchdog.d.ts +63 -0
  19. package/dist/shared/command-watchdog.js +87 -0
  20. package/dist/shared/data-home.d.ts +8 -0
  21. package/dist/shared/data-home.js +14 -0
  22. package/dist/shared/model-endpoint.d.ts +53 -0
  23. package/dist/shared/model-endpoint.js +98 -2
  24. package/dist/shared/reasoning-capability.d.ts +25 -5
  25. package/dist/shared/reasoning-capability.js +18 -9
  26. package/dist/task/auto-orchestrator.js +14 -3
  27. package/dist/task/child-runner.d.ts +92 -15
  28. package/dist/task/child-runner.js +303 -66
  29. package/dist/task/context-usage.d.ts +46 -0
  30. package/dist/task/context-usage.js +41 -0
  31. package/dist/task/failure-classifier.js +24 -1
  32. package/dist/task/gate-child.d.ts +15 -4
  33. package/dist/task/gate-child.js +2 -2
  34. package/dist/task/gate-deps.js +7 -2
  35. package/dist/task/implementation-guards.d.ts +26 -0
  36. package/dist/task/implementation-guards.js +177 -0
  37. package/dist/task/implementation-hold.d.ts +118 -0
  38. package/dist/task/implementation-hold.js +165 -0
  39. package/dist/task/implementation-turn.d.ts +5 -0
  40. package/dist/task/implementation-turn.js +12 -1
  41. package/dist/task/loop-detector.d.ts +18 -0
  42. package/dist/task/loop-detector.js +22 -2
  43. package/dist/task/model-hold-stash.d.ts +43 -0
  44. package/dist/task/model-hold-stash.js +70 -0
  45. package/dist/task/orchestrator.d.ts +18 -5
  46. package/dist/task/orchestrator.js +63 -6
  47. package/dist/task/phases.js +18 -5
  48. package/dist/task/research-worker.d.ts +2 -2
  49. package/dist/task/research-worker.js +1 -1
  50. package/dist/workers/docs-core.js +2 -2
  51. package/dist/workers/docs-lookup.d.ts +4 -3
  52. package/dist/workers/docs-lookup.js +1 -1
  53. package/dist/workers/fetch-core.js +2 -2
  54. package/dist/workers/focused-extractor.d.ts +6 -4
  55. package/dist/workers/focused-extractor.js +17 -5
  56. package/dist/workers/index.js +2 -0
  57. package/dist/workers/model-warning.d.ts +69 -0
  58. package/dist/workers/model-warning.js +113 -0
  59. package/dist/workers/pi-worker-core.d.ts +9 -38
  60. package/dist/workers/pi-worker-core.js +8 -86
  61. package/dist/workers/pi-worker-docs.js +2 -2
  62. package/dist/workers/pi-worker.js +4 -4
  63. package/dist/workers/reasoning-warning.d.ts +17 -9
  64. package/dist/workers/reasoning-warning.js +69 -22
  65. package/dist/workers/single-read-guard.d.ts +6 -6
  66. package/dist/workers/single-read-guard.js +8 -8
  67. package/dist/workers/worker-profiles.d.ts +11 -3
  68. package/dist/workers/worker-profiles.js +33 -1
  69. package/package.json +1 -1
  70. package/dist/config/reasoning-args.d.ts +0 -23
  71. package/dist/config/reasoning-args.js +0 -28
  72. package/dist/task/implementation-thinking.d.ts +0 -56
  73. package/dist/task/implementation-thinking.js +0 -32
@@ -9,21 +9,21 @@ import { spawn } from 'node:child_process';
9
9
  import { getPiInvocation } from '../shared/pi-invocation.js';
10
10
  import { runChild as runChildUnified } from '../shared/child-process.js';
11
11
  import { childBaseArgs } from '../shared/child-extensions.js';
12
- import { LoopDetector } from './loop-detector.js';
12
+ import { LoopDetector, MAX_LOOP_RESTARTS } from './loop-detector.js';
13
13
  import { StallDetector, formatStallHint } from './stall-detector.js';
14
14
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
15
15
  import { readSection, setTaskSection } from './task-io.js';
16
16
  import { streamStallCause } from '../shared/stream-watchdog.js';
17
+ import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../shared/command-watchdog.js';
18
+ import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
19
+ import { modelSpecFromArgs } from '../config/group-models.js';
20
+ // VALUE import, and it is only safe because worker-profiles.ts reads its loop
21
+ // constants from loop-detector.ts. Point those back at this file and the graph
22
+ // closes into a TDZ ReferenceError that no compile step catches.
23
+ import { workerPolicy } from '../workers/worker-profiles.js';
17
24
  import { getConfig } from '../config/config.js';
18
- import { groupThinkingArgs } from '../config/reasoning-args.js';
19
- import { reasoningGroupForChild } from '../config/reasoning.js';
20
- // ─── Loop detection constants ────────────────────────────────────────────────
21
- // Defined here (not in phases.ts) to avoid a circular dependency:
22
- // phases.ts → child-runner.ts → phases.ts
23
- export const LOOP_WINDOW = 20;
24
- export const LOOP_THRESHOLD = 5;
25
- export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
26
- // MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
25
+ import { groupChildArgs, groupWindow } from '../config/group-args.js';
26
+ import { groupForChild } from '../config/groups.js';
27
27
  // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
28
28
  /**
29
29
  * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
@@ -105,6 +105,75 @@ export class PhaseTimeoutError extends Error {
105
105
  this.name = 'PhaseTimeoutError';
106
106
  }
107
107
  }
108
+ /**
109
+ * The terminal error for a guard kill, or null when the child was not killed.
110
+ *
111
+ * Both spawn paths must ask. A kill reports `exitCode: 0` (child-process.ts uses
112
+ * `code ?? 0`, and a signal gives null), so a path that tests the exit code
113
+ * instead returns the truncated text as the phase's answer.
114
+ */
115
+ export function guardKillError(name, r, opts = {}) {
116
+ if (r.commandKill)
117
+ return new CommandTimeoutError(name, r.commandKill);
118
+ // A dead-backend verdict is only trusted once every attempt has produced it.
119
+ // The probe now asks about this child's own endpoint rather than ORing over
120
+ // every provider, so it is exact — but it is still one network call at one
121
+ // instant, and a blip is indistinguishable from a death in a single sample.
122
+ // The asymmetry settles it: a backend that really is down costs three 5s
123
+ // probes, a wrong verdict costs the whole run.
124
+ if (r.stalled)
125
+ return opts.finalAttempt === false ? null : new BackendDownError(name);
126
+ return null;
127
+ }
128
+ /**
129
+ * The dead-backend probe killed a phase child on its LAST attempt.
130
+ *
131
+ * Reaching this means every attempt found no endpoint answering, not one. The
132
+ * single-probe verdict is not trusted on its own, because one sample cannot tell
133
+ * a dead server from a blip. Three failed probes cost ~15s; one wrong verdict
134
+ * costs the run.
135
+ */
136
+ export class BackendDownError extends Error {
137
+ childName;
138
+ constructor(childName) {
139
+ super(`${childName} child killed: no output for the stall window and the model `
140
+ + `endpoint did not answer a probe`);
141
+ this.childName = childName;
142
+ this.name = 'BackendDownError';
143
+ }
144
+ }
145
+ /**
146
+ * A phase child spent every attempt on a command that never returned. Its own
147
+ * class because the fix is in the SPEC, not the model's exploration: a VERIFY
148
+ * block naming an unbounded `dev` command re-hangs every attempt.
149
+ */
150
+ export class CommandTimeoutError extends Error {
151
+ childName;
152
+ kill;
153
+ constructor(childName, kill) {
154
+ super(`${childName} child ran \`${kill.toolName}\``
155
+ + `${kill.detail ? ` (${kill.detail})` : ''} past its `
156
+ + `${Math.round(kill.timeoutMs / 1000)}s ceiling on every attempt`);
157
+ this.childName = childName;
158
+ this.kill = kill;
159
+ this.name = 'CommandTimeoutError';
160
+ }
161
+ }
162
+ /**
163
+ * Causes a best-effort `catch` must NOT absorb.
164
+ *
165
+ * A phase child that merely answered badly should degrade — that is what those
166
+ * catches are for. These two are different in kind: the run is over either way,
167
+ * and swallowing them ships a half-built spec while every later phase dies
168
+ * against the same dead backend, or turns a user's ESC into silent progress.
169
+ * `failure-classifier.ts` has a verdict for both; a catch that eats them makes it
170
+ * unreachable.
171
+ */
172
+ export function isFatalChildCause(e) {
173
+ if (e instanceof BackendDownError)
174
+ return true;
175
+ return e instanceof Error && e.message === USER_CANCELLED;
176
+ }
108
177
  // ─── Connection-error retry ──────────────────────────────────────────────────
109
178
  /**
110
179
  * A connection-class model error is transient: a single dropped fetch to a live
@@ -120,9 +189,54 @@ export class PhaseTimeoutError extends Error {
120
189
  * provider 5xx that names a real fault) still fails fast: re-spawning against
121
190
  * the same request won't fix it, so burning the budget only delays the report.
122
191
  */
123
- const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated|unreachable)\b/i;
192
+ /**
193
+ * Transport-level failures worth another attempt.
194
+ *
195
+ * SCOPE, and it is deliberate: connection classes only. pi's own
196
+ * `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
197
+ * retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
198
+ * which `does NOT match real, non-transient faults` in child-runner.test.ts
199
+ * explicitly rejects. That disagreement is real and OPEN; it is not settled here,
200
+ * because this backoff starts at 500ms and a 429 answered that fast is a retry
201
+ * storm, not a recovery.
202
+ *
203
+ * MEASURED against pi before widening: the transport entries added here — a bare
204
+ * `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
205
+ * headers`, a truncated Anthropic stream and a closed websocket — were all
206
+ * MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
207
+ * setup never surfaced the gap. The errno spellings are pi-task's own: a child
208
+ * reports them through stderr, and pi never sees them.
209
+ *
210
+ * pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
211
+ * that merely echoed a `timeout` field back, turning a fail-fast into a full
212
+ * retry budget, and it caught nothing the `timed out` spellings above miss.
213
+ */
214
+ const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|socket connection was closed|fetch failed|network (?:error|timeout)|premature close|terminated|unreachable|getaddrinfo|enotfound|upstream.?connect|reset before headers|timed? out|ended without|stream ended before message_stop|websocket.?(?:closed|error))\b/i;
215
+ /**
216
+ * Provider LOAD, which is transient in a different way: the server is up and
217
+ * saying "not now". pi retries all of these; 53f0488 did not, but its own message
218
+ * names only "context overflow, bad request, auth" as the fail-fast set — a
219
+ * throttle was never argued for, it just rode along in a list written for a LOCAL
220
+ * server, where none of these can occur.
221
+ *
222
+ * Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
223
+ * character); the bare status codes carry one, or `500` matches inside `15000`.
224
+ */
225
+ const PROVIDER_LOAD_RE = /(?:overloaded|rate.?limit|too many requests|service.?unavailable|server.?error|internal.?error|provider.?returned.?error)|\b(?:429|500|502|503|504|524)\b/i;
226
+ /**
227
+ * Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
228
+ * because these arrive worded as a throttle — `429 GoUsageLimitError` is a
229
+ * subscription limit, not a queue.
230
+ */
231
+ const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
232
+ /**
233
+ * Retry budget is three attempts at 500ms/1s/2s — three requests over 3.5s, which
234
+ * is not a storm even against a throttle. pi's own ladder is three at 2s/4s/8s.
235
+ */
124
236
  export function isConnectionError(cause) {
125
- return CONNECTION_ERROR_RE.test(cause);
237
+ if (NON_RETRYABLE_RE.test(cause))
238
+ return false;
239
+ return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
126
240
  }
127
241
  /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
128
242
  * brief saturation window can drain before we re-issue the request. */
@@ -133,12 +247,16 @@ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
133
247
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
134
248
  export function childArgs(tools, extensions = [],
135
249
  /**
136
- * An already-resolved `['--thinking', level]`, or `[]` for "emit no flag".
137
- * Resolved by the CALLER, never here: the level is a property of the child's
138
- * ROLE, and this function is handed tools and extensions, not a name.
139
- * Omitted ⇒ byte-identical argv to the version before reasoning profiles.
250
+ * This child's group fragment: `--model` then `--thinking`, either half
251
+ * possibly absent. Resolved by the CALLER, never here both are properties
252
+ * of the child's ROLE, and this function is handed tools and extensions, not
253
+ * a name. Omitted ⇒ byte-identical argv to the version before group profiles.
254
+ *
255
+ * ONE field rather than a `model` beside a `thinking`, because nothing may
256
+ * compose the two halves by hand: `groupChildArgs` is the only producer, so a
257
+ * doubled `--thinking` is unreachable rather than merely unlikely.
140
258
  */
141
- thinking = []) {
259
+ groupArgs = []) {
142
260
  // `--mode json` puts the child into the structured event stream the
143
261
  // unified runner parses in `mode: 'json-events'`. Without it the child
144
262
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -160,35 +278,80 @@ thinking = []) {
160
278
  // one — the guards all hang off pi's `tool_call` hook.
161
279
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
162
280
  const internal = tools === '' ? [] : extensions;
163
- return [...childBaseArgs(internal), ...thinking, '--mode', 'json', ...toolFlags];
281
+ return [...childBaseArgs(internal), ...groupArgs, '--mode', 'json', ...toolFlags];
164
282
  }
165
283
  // Sentinel error thrown when the user dismisses a grill-me dialog.
166
284
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
167
285
  export const USER_CANCELLED = '__user_cancelled__';
168
- export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }) {
169
- const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
286
+ /**
287
+ * The `phase` row of WORKER_PROFILES, resolved with this machine's config.
288
+ *
289
+ * Read here rather than at module load so a /task-config change reaches the next
290
+ * child, the same contract childBaseArgs already keeps. Both spawn paths in this
291
+ * file go through it, so the degraded final attempt cannot drift from the ordinary
292
+ * one — the mislabel class runDegradedFinalAttempt's own comment warns about.
293
+ */
294
+ export function phasePolicy() {
295
+ return workerPolicy('phase', {
296
+ commandTimeoutMs: getConfig().requestTimeoutMs,
297
+ streamInactivityMs: getConfig().streamInactivityMs
298
+ });
299
+ }
300
+ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, groupArgs, commandCeilingMs }) {
301
+ const invocation = getPiInvocation(childArgs(tools, extensions, groupArgs), prompt);
170
302
  let loopHit;
171
- const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
172
- mode: 'json-events',
173
- // A hung model stream reports nothing at all, so without this the
174
- // phase child waits forever. The kill
175
- // is reported below as a connection-class cause, which routes it into
176
- // the retry/backoff path this file already has for a LOUD disconnect.
177
- streamInactivityMs: getConfig().streamInactivityMs,
178
- onLine,
179
- onContextUsage,
180
- ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
181
- onToolResult: onToolResult ? r => onToolResult(r.text, r.isError) : undefined,
182
- onToolCall: call => {
183
- if (!onToolCall)
184
- return null;
185
- const hit = onToolCall(call);
186
- if (hit && !loopHit) {
187
- loopHit = hit;
303
+ const guards = phasePolicy().guards;
304
+ // Null when the user set the ceiling to `off`. Why a phase child needs this at
305
+ // all is the `phase` row's `why` in worker-profiles.ts.
306
+ const cmdWatch = commandWatch(commandCeilingMs ?? guards['command-timeout']);
307
+ const childSignal = cmdWatch ? AbortSignal.any([signal, cmdWatch.signal]) : signal;
308
+ let result;
309
+ try {
310
+ result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, childSignal, {
311
+ mode: 'json-events',
312
+ // A hung model stream reports nothing at all, so without this the
313
+ // phase child waits forever. The kill
314
+ // is reported below as a connection-class cause, which routes it into
315
+ // the retry/backoff path this file already has for a LOUD disconnect.
316
+ streamInactivityMs: guards['stream-stall'],
317
+ ...(guards.stalled === false ?
318
+ {}
319
+ : {
320
+ stall: {
321
+ afterMs: guards.stalled.afterMs,
322
+ probe: guards.stalled.probe
323
+ ?? (() => probeModelEndpoints(childModelEndpoints(modelSpecFromArgs(groupArgs ?? []))))
324
+ }
325
+ }),
326
+ onLine,
327
+ onContextUsage,
328
+ ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
329
+ // ALWAYS wired, never conditional on the caller wanting results:
330
+ // the sink emits a tool-execution-end only when a handler exists,
331
+ // and without that end the command watchdog's timer is never
332
+ // disarmed — every healthy tool call would then look hung.
333
+ onToolResult: r => {
334
+ cmdWatch?.onEnd(r.toolCallId);
335
+ onToolResult?.(r.text, r.isError);
336
+ },
337
+ onToolCall: call => {
338
+ // Before the detectors: a call they let through still needs its
339
+ // clock started.
340
+ cmdWatch?.onStart(call);
341
+ if (!onToolCall)
342
+ return null;
343
+ const hit = onToolCall(call);
344
+ if (hit && !loopHit) {
345
+ loopHit = hit;
346
+ }
347
+ return hit; // propagate to unified runner so it can kill
188
348
  }
189
- return hit; // propagate to unified runner so it can kill
190
- }
191
- });
349
+ });
350
+ }
351
+ finally {
352
+ cmdWatch?.clear();
353
+ }
354
+ const commandKill = cmdWatch?.killed();
192
355
  // Use `||` (not `??`) so an empty string from json-events mode falls
193
356
  // back to raw stdout. Without this, a child that exits 0 but emits no
194
357
  // assistant text (e.g. model API error swallowed in json mode) always
@@ -202,14 +365,16 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
202
365
  ?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
203
366
  return {
204
367
  text,
205
- // WE killed this child, so its exit status describes our own SIGTERM, not
206
- // the child's verdict. Report 0 and let `modelError` carry the cause
207
- // otherwise the wrappers' `exitCode !== 0` guard throws a bare "child
208
- // failed" before the connection-error retry ever gets to look.
209
- exitCode: result.streamStalled ? 0 : result.exitCode,
368
+ // WE killed this child, so its exit status is our own SIGTERM. Report 0
369
+ // and let the named cause carry it. EVERY guard kill must be listed: one
370
+ // omitted here arrives as exit 0 with partial text, and triageChildResult
371
+ // returns it as a successful answer.
372
+ exitCode: result.streamStalled || result.stalled || commandKill ? 0 : result.exitCode,
210
373
  stderr: result.stderr.trim(),
211
374
  loopHit,
212
375
  modelError,
376
+ ...(commandKill ? { commandKill } : {}),
377
+ ...(result.stalled ? { stalled: true } : {}),
213
378
  // A tool call the model wrote as text (wrong dialect) never executed and
214
379
  // sailed past the structured-event guards above; flag it so the wrappers
215
380
  // can re-prompt instead of accepting the unexecuted call. Only meaningful
@@ -301,17 +466,17 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
301
466
  * exit status describes our SIGTERM and says nothing about its verdict.
302
467
  */
303
468
  /**
304
- * The `--thinking` fragment for a named child, or `[]` when the name is unmapped.
469
+ * The group fragment for a named child, or `[]` when the name is unmapped.
305
470
  *
306
471
  * An unmapped name INHERITS rather than throwing: a child that reaches the model
307
472
  * with today's argv is always safe, and aborting a user's task over a missing
308
473
  * table row would be a worse failure than the one it reports. The guard that
309
- * makes the table complete is `reasoning-groups.test.ts`, which fails the BUILD —
474
+ * makes the table complete is `config/groups.test.ts`, which fails the BUILD —
310
475
  * where someone can actually fix it.
311
476
  */
312
- export function thinkingForChild(name) {
313
- const group = reasoningGroupForChild(name);
314
- return group ? groupThinkingArgs(group) : [];
477
+ export function groupArgsForChild(name) {
478
+ const group = groupForChild(name);
479
+ return group ? groupChildArgs(group) : [];
315
480
  }
316
481
  /**
317
482
  * What a PHASE child's invocation carries, said once.
@@ -322,14 +487,29 @@ export function thinkingForChild(name) {
322
487
  * which is what the degrade's own comment ("the degrade changes the TOOLS, not
323
488
  * the role") claimed while three bare `undefined`s quietly made it false.
324
489
  */
325
- function phaseChildRun(deps, over) {
490
+ /**
491
+ * The context window a child of this group runs against.
492
+ *
493
+ * The GROUP's window when this session resolved one, else the run's. Too small a
494
+ * window makes the churn rule fire early and kill a healthy child, so an
495
+ * `inherit` or unresolved group keeps the parent's number rather than a guess.
496
+ */
497
+ function childContextWindow(deps, group) {
498
+ return (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow;
499
+ }
500
+ function phaseChildRun(deps,
501
+ /** This child's group, for the window. Undefined ⇒ the run's own window. */
502
+ group, over) {
326
503
  return {
327
504
  cwd: deps.cwd,
328
505
  onLine: deps.onChildOutput,
329
506
  onContextUsage: deps.onContextUsage,
330
507
  spawn: deps.spawn,
331
508
  extensions: deps.childExtensions,
332
- contextWindow: deps.contextWindow,
509
+ // The GROUP's window when this session resolved one, else the run's.
510
+ // Too small a window makes the churn rule fire early and kill a healthy
511
+ // child, so an unresolved group keeps the parent's number.
512
+ contextWindow: (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow,
333
513
  ...over
334
514
  };
335
515
  }
@@ -340,41 +520,61 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
340
520
  // between a loop-kill and its retry would otherwise make the two attempts
341
521
  // different experiments, and the retry exists to repeat the first one with a
342
522
  // hint. An unmapped name inherits, which is today's argv — the build-time
343
- // guard for that is reasoning-groups.test.ts, not a throw in a user's run.
344
- const thinking = thinkingForChild(name);
523
+ // guard for that is config/groups.test.ts, not a throw in a user's run.
524
+ const groupArgs = groupArgsForChild(name);
525
+ const group = groupForChild(name);
345
526
  const verb = opts.verb ?? 'retry';
346
527
  let hint = null;
347
528
  const loopHistory = [];
348
529
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
530
+ // From the `phase` row, not from literals here, so the table is the one place
531
+ // that answers "how may this child die". The row resolves to the same
532
+ // DEFAULT_LOOP_DETECTOR / DEFAULT_LOOP_PROGRESS this file used to hard-code.
533
+ const loopGuard = phasePolicy().guards.loop;
534
+ // Watchdog kills, NOT total strikes: a loop restart never saw the
535
+ // bound-your-command hint. Without the halving, three attempts at the default
536
+ // ceiling cost 45 minutes and nothing else bounds this path.
537
+ let hangKills = 0;
349
538
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
350
539
  // A cancel between attempts must not buy another spawn.
351
540
  if (deps.signal.aborted)
352
541
  throw new Error(USER_CANCELLED);
353
- const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
354
- const stall = new StallDetector();
542
+ const detector = loopGuard.detector === false ?
543
+ null
544
+ : new LoopDetector(loopGuard.detector.window, loopGuard.detector.threshold, loopGuard.detector.pathThreshold);
545
+ const stall = loopGuard.progress === false ?
546
+ null
547
+ : new StallDetector(loopGuard.progress.limit, loopGuard.progress.churnFactor);
355
548
  // Arm the churn rule BEFORE the first tool call. pi's stream carries no
356
549
  // context WINDOW, so a detector that waited to be told one would sit at 0,
357
550
  // and the churn rule returns false on a non-positive window. The parent
358
551
  // knows the value at spawn time — say it then, not later.
359
- stall.noteContext(deps.contextWindow ?? 0);
552
+ //
553
+ // The SAME number the child is handed below. Arming it from the run's
554
+ // window while the child runs on a bigger model's would judge the child
555
+ // against a window it does not have, for exactly the stretch before the
556
+ // first `context_usage` event corrects it — which is the stretch this
557
+ // line exists to cover.
558
+ stall?.noteContext(childContextWindow(deps, group) ?? 0);
360
559
  const clock = phaseTimeout(deps.signal, budgetMs);
361
560
  let r;
362
561
  try {
363
- r = await runChild(phaseChildRun(deps, {
562
+ r = await runChild(phaseChildRun(deps, group, {
364
563
  tools,
365
564
  prompt: prependHint(hint, prompt),
366
565
  signal: clock.signal,
367
- thinking,
566
+ groupArgs,
368
567
  onContextUsage: snapshot => {
369
568
  // Real window or nothing: `noteContext` ignores 0, which is
370
569
  // why the parent's value must be supplied at spawn — a
371
570
  // stream that only ever reports 0 leaves the churn rule
372
571
  // permanently disarmed.
373
- stall.noteContext(snapshot.contextWindow);
572
+ stall?.noteContext(snapshot.contextWindow);
374
573
  deps.onContextUsage?.(snapshot);
375
574
  },
376
- onToolCall: call => detector.record(call) ?? stall.record(call),
377
- onToolResult: (text, isError) => stall.noteResult(text, isError)
575
+ commandCeilingMs: commandCeilingForAttempt(phasePolicy().guards['command-timeout'], hangKills),
576
+ onToolCall: call => detector?.record(call) ?? stall?.record(call) ?? null,
577
+ onToolResult: (text, isError) => stall?.noteResult(text, isError)
378
578
  }));
379
579
  }
380
580
  finally {
@@ -404,6 +604,36 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
404
604
  hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
405
605
  continue;
406
606
  }
607
+ // Ordered after the loop rule and before the clock, matching RESTART_ORDER
608
+ // (worker-kill.ts): the loop hint names the offending call and is the more
609
+ // specific thing to tell a re-spawn, and a watchdog kill leaves the phase
610
+ // clock's own flag false, so the two cannot be confused.
611
+ if (r.commandKill) {
612
+ hangKills++;
613
+ if (attempt === MAX_LEAK_RETRIES) {
614
+ throw new CommandTimeoutError(name, r.commandKill);
615
+ }
616
+ deps.logDebug?.(`${name}: ${r.commandKill.toolName} outran `
617
+ + `${Math.round(r.commandKill.timeoutMs / 1000)}s — `
618
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
619
+ // Tracks the TOOLS, not the phase: verify-tooling holds `read,bash`,
620
+ // and a half-written node_modules survives the kill.
621
+ hint = commandTimeoutHint(r.commandKill.toolName, r.commandKill.timeoutMs, {
622
+ ...(r.commandKill.detail ? { commandDetail: r.commandKill.detail } : {}),
623
+ editsMayPersist: /\b(?:bash|edit|write)\b/.test(tools)
624
+ });
625
+ continue;
626
+ }
627
+ // A dead-backend kill spends a strike like the others; guardKillError says
628
+ // when the verdict has been earned.
629
+ const killed = guardKillError(name, r, { finalAttempt: attempt === MAX_LEAK_RETRIES });
630
+ if (killed)
631
+ throw killed;
632
+ if (r.stalled) {
633
+ deps.logDebug?.(`${name}: no output for the stall window and no endpoint answered — `
634
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
635
+ continue;
636
+ }
407
637
  if (clock.timedOut()) {
408
638
  if (attempt === MAX_LEAK_RETRIES) {
409
639
  throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
@@ -488,19 +718,26 @@ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
488
718
  const clock = phaseTimeout(deps.signal, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS);
489
719
  let r;
490
720
  try {
491
- r = await runChild(phaseChildRun(deps, {
721
+ r = await runChild(phaseChildRun(deps, groupForChild(name), {
492
722
  tools: '', // --no-tools: the model cannot read/grep/list, only answer
493
723
  prompt: prependHint(formatDegradeHint(hit), prompt),
494
724
  signal: clock.signal,
495
725
  // Same group as the attempts that led here. The degrade changes the
496
- // TOOLS, not the role — running it at a different thinking level would
497
- // make the fallback a different experiment from the thing it rescues.
498
- thinking: thinkingForChild(name)
726
+ // TOOLS, not the role — running it on a different model, or at a
727
+ // different thinking level, would make the fallback a different
728
+ // experiment from the thing it rescues.
729
+ groupArgs: groupArgsForChild(name)
499
730
  }));
500
731
  }
501
732
  finally {
502
733
  clock.cleanup();
503
734
  }
735
+ // BEFORE the exit-code test, not inside it: a guard kill reports exit 0, so
736
+ // asking afterwards would already have returned the truncated text as this
737
+ // phase's deliverable.
738
+ const killed = guardKillError(name, r);
739
+ if (killed)
740
+ throw killed;
504
741
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
505
742
  // A wall-clock kill is NOT a loop. Without this check a child that outran
506
743
  // its budget is reported as "loop budget exhausted", carrying a loop
@@ -5,6 +5,8 @@
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
7
  import type { ContextSnapshot } from '../shared/child-process.js';
8
+ import { type PiTaskConfig } from '../config/config.js';
9
+ import type { ChildGroup } from '../config/groups.js';
8
10
  /**
9
11
  * The parent session's context window, or 0 when the model doesn't expose it.
10
12
  *
@@ -22,6 +24,50 @@ export declare function getParentContextWindow(ctx: {
22
24
  contextWindow?: number;
23
25
  };
24
26
  }): number;
27
+ /**
28
+ * The window for the model ONE GROUP's children will actually run on.
29
+ *
30
+ * This number drives the widget and, more importantly, `StallDetector`'s
31
+ * context-churn rule, and the two error directions are not symmetric. A parent
32
+ * window LARGER than the child's makes churn fire late — degraded, and the
33
+ * no-new-ground rule still covers it. A parent window SMALLER makes churn fire
34
+ * early and KILL A HEALTHY CHILD. A big-context research model under a small
35
+ * host model is a real false positive, which is why this exists at all.
36
+ *
37
+ * For the same reason there is no `min(parent, group)`: that would import the
38
+ * dangerous direction on purpose.
39
+ *
40
+ * `inherit`, an unresolvable spec, or a model with no declared window all return
41
+ * exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
42
+ * per-group models existed.
43
+ *
44
+ * Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
45
+ * which the session pass fills from this. One producer, so the two views cannot
46
+ * describe different models.
47
+ */
48
+ export declare function contextWindowForGroup(ctx: {
49
+ model?: {
50
+ contextWindow?: number;
51
+ };
52
+ modelRegistry?: {
53
+ find: (p: string, i: string) => unknown;
54
+ };
55
+ }, group: ChildGroup, cfg?: PiTaskConfig): number;
56
+ /**
57
+ * The same answer for a spec the caller already has.
58
+ *
59
+ * The session pass needs this: it walks an INJECTED spec table, and reaching for
60
+ * `getConfig()` here would let the window it stores describe a different model
61
+ * from the one it just checked.
62
+ */
63
+ export declare function contextWindowForSpec(ctx: {
64
+ model?: {
65
+ contextWindow?: number;
66
+ };
67
+ modelRegistry?: {
68
+ find: (p: string, i: string) => unknown;
69
+ };
70
+ }, spec: string): number;
25
71
  /**
26
72
  * Fold a raw context snapshot into a display snapshot: prefer the child's own
27
73
  * contextWindow, else the last known one, else the parent session's; then derive
@@ -4,6 +4,8 @@
4
4
  * the planning and gate children; the single-task widget (TaskRunner) calls it
5
5
  * directly, because its state is the whole-run `WidgetState`, not one child's.
6
6
  */
7
+ import { getConfig } from '../config/config.js';
8
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
7
9
  /**
8
10
  * The parent session's context window, or 0 when the model doesn't expose it.
9
11
  *
@@ -19,6 +21,45 @@
19
21
  export function getParentContextWindow(ctx) {
20
22
  return ctx.model?.contextWindow ?? 0;
21
23
  }
24
+ /**
25
+ * The window for the model ONE GROUP's children will actually run on.
26
+ *
27
+ * This number drives the widget and, more importantly, `StallDetector`'s
28
+ * context-churn rule, and the two error directions are not symmetric. A parent
29
+ * window LARGER than the child's makes churn fire late — degraded, and the
30
+ * no-new-ground rule still covers it. A parent window SMALLER makes churn fire
31
+ * early and KILL A HEALTHY CHILD. A big-context research model under a small
32
+ * host model is a real false positive, which is why this exists at all.
33
+ *
34
+ * For the same reason there is no `min(parent, group)`: that would import the
35
+ * dangerous direction on purpose.
36
+ *
37
+ * `inherit`, an unresolvable spec, or a model with no declared window all return
38
+ * exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
39
+ * per-group models existed.
40
+ *
41
+ * Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
42
+ * which the session pass fills from this. One producer, so the two views cannot
43
+ * describe different models.
44
+ */
45
+ export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
46
+ return contextWindowForSpec(ctx, cfg.groupModels[group]);
47
+ }
48
+ /**
49
+ * The same answer for a spec the caller already has.
50
+ *
51
+ * The session pass needs this: it walks an INJECTED spec table, and reaching for
52
+ * `getConfig()` here would let the window it stores describe a different model
53
+ * from the one it just checked.
54
+ */
55
+ export function contextWindowForSpec(ctx, spec) {
56
+ if (spec === MODEL_INHERIT)
57
+ return getParentContextWindow(ctx);
58
+ const parts = splitSpec(spec);
59
+ const found = parts && ctx.modelRegistry?.find(parts.provider, parts.id);
60
+ const window = found?.contextWindow ?? 0;
61
+ return window > 0 ? window : getParentContextWindow(ctx);
62
+ }
22
63
  /**
23
64
  * Fold a raw context snapshot into a display snapshot: prefer the child's own
24
65
  * contextWindow, else the last known one, else the parent session's; then derive