@mjasnikovs/pi-task 0.38.30 → 0.38.31

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.
@@ -9,21 +9,20 @@ 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 { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
19
+ // VALUE import, and it is only safe because worker-profiles.ts reads its loop
20
+ // constants from loop-detector.ts. Point those back at this file and the graph
21
+ // closes into a TDZ ReferenceError that no compile step catches.
22
+ import { workerPolicy } from '../workers/worker-profiles.js';
17
23
  import { getConfig } from '../config/config.js';
18
24
  import { groupThinkingArgs } from '../config/reasoning-args.js';
19
25
  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).
27
26
  // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
28
27
  /**
29
28
  * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
@@ -105,6 +104,75 @@ export class PhaseTimeoutError extends Error {
105
104
  this.name = 'PhaseTimeoutError';
106
105
  }
107
106
  }
107
+ /**
108
+ * The terminal error for a guard kill, or null when the child was not killed.
109
+ *
110
+ * Both spawn paths must ask. A kill reports `exitCode: 0` (child-process.ts uses
111
+ * `code ?? 0`, and a signal gives null), so a path that tests the exit code
112
+ * instead returns the truncated text as the phase's answer.
113
+ */
114
+ export function guardKillError(name, r, opts = {}) {
115
+ if (r.commandKill)
116
+ return new CommandTimeoutError(name, r.commandKill);
117
+ // A dead-backend verdict is only trusted once every attempt has produced it.
118
+ // `discoverModelEndpoints` reads EVERY provider in models.json, not the one
119
+ // this child's model uses, so a stopped local server can condemn a run against
120
+ // a healthy cloud backend. The asymmetry settles it: a backend that really is
121
+ // down costs three 5s probes, a wrong verdict costs the whole run.
122
+ if (r.stalled)
123
+ return opts.finalAttempt === false ? null : new BackendDownError(name);
124
+ return null;
125
+ }
126
+ /**
127
+ * The dead-backend probe killed a phase child on its LAST attempt.
128
+ *
129
+ * Reaching this means every attempt found no endpoint answering, not one. The
130
+ * single-probe verdict is not trusted on its own: `discoverModelEndpoints` reads
131
+ * every provider in models.json rather than the one this child's model uses, so a
132
+ * stopped local server can condemn a run against a healthy cloud backend. Three
133
+ * failed probes cost ~15s; one wrong verdict costs the run.
134
+ */
135
+ export class BackendDownError extends Error {
136
+ childName;
137
+ constructor(childName) {
138
+ super(`${childName} child killed: no output for the stall window and the model `
139
+ + `endpoint did not answer a probe`);
140
+ this.childName = childName;
141
+ this.name = 'BackendDownError';
142
+ }
143
+ }
144
+ /**
145
+ * A phase child spent every attempt on a command that never returned. Its own
146
+ * class because the fix is in the SPEC, not the model's exploration: a VERIFY
147
+ * block naming an unbounded `dev` command re-hangs every attempt.
148
+ */
149
+ export class CommandTimeoutError extends Error {
150
+ childName;
151
+ kill;
152
+ constructor(childName, kill) {
153
+ super(`${childName} child ran \`${kill.toolName}\``
154
+ + `${kill.detail ? ` (${kill.detail})` : ''} past its `
155
+ + `${Math.round(kill.timeoutMs / 1000)}s ceiling on every attempt`);
156
+ this.childName = childName;
157
+ this.kill = kill;
158
+ this.name = 'CommandTimeoutError';
159
+ }
160
+ }
161
+ /**
162
+ * Causes a best-effort `catch` must NOT absorb.
163
+ *
164
+ * A phase child that merely answered badly should degrade — that is what those
165
+ * catches are for. These two are different in kind: the run is over either way,
166
+ * and swallowing them ships a half-built spec while every later phase dies
167
+ * against the same dead backend, or turns a user's ESC into silent progress.
168
+ * `failure-classifier.ts` has a verdict for both; a catch that eats them makes it
169
+ * unreachable.
170
+ */
171
+ export function isFatalChildCause(e) {
172
+ if (e instanceof BackendDownError)
173
+ return true;
174
+ return e instanceof Error && e.message === USER_CANCELLED;
175
+ }
108
176
  // ─── Connection-error retry ──────────────────────────────────────────────────
109
177
  /**
110
178
  * A connection-class model error is transient: a single dropped fetch to a live
@@ -120,9 +188,54 @@ export class PhaseTimeoutError extends Error {
120
188
  * provider 5xx that names a real fault) still fails fast: re-spawning against
121
189
  * the same request won't fix it, so burning the budget only delays the report.
122
190
  */
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;
191
+ /**
192
+ * Transport-level failures worth another attempt.
193
+ *
194
+ * SCOPE, and it is deliberate: connection classes only. pi's own
195
+ * `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
196
+ * retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
197
+ * which `does NOT match real, non-transient faults` in child-runner.test.ts
198
+ * explicitly rejects. That disagreement is real and OPEN; it is not settled here,
199
+ * because this backoff starts at 500ms and a 429 answered that fast is a retry
200
+ * storm, not a recovery.
201
+ *
202
+ * MEASURED against pi before widening: the transport entries added here — a bare
203
+ * `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
204
+ * headers`, a truncated Anthropic stream and a closed websocket — were all
205
+ * MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
206
+ * setup never surfaced the gap. The errno spellings are pi-task's own: a child
207
+ * reports them through stderr, and pi never sees them.
208
+ *
209
+ * pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
210
+ * that merely echoed a `timeout` field back, turning a fail-fast into a full
211
+ * retry budget, and it caught nothing the `timed out` spellings above miss.
212
+ */
213
+ 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;
214
+ /**
215
+ * Provider LOAD, which is transient in a different way: the server is up and
216
+ * saying "not now". pi retries all of these; 53f0488 did not, but its own message
217
+ * names only "context overflow, bad request, auth" as the fail-fast set — a
218
+ * throttle was never argued for, it just rode along in a list written for a LOCAL
219
+ * server, where none of these can occur.
220
+ *
221
+ * Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
222
+ * character); the bare status codes carry one, or `500` matches inside `15000`.
223
+ */
224
+ 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;
225
+ /**
226
+ * Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
227
+ * because these arrive worded as a throttle — `429 GoUsageLimitError` is a
228
+ * subscription limit, not a queue.
229
+ */
230
+ const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
231
+ /**
232
+ * Retry budget is three attempts at 500ms/1s/2s — three requests over 3.5s, which
233
+ * is not a storm even against a throttle. pi's own ladder is three at 2s/4s/8s.
234
+ */
124
235
  export function isConnectionError(cause) {
125
- return CONNECTION_ERROR_RE.test(cause);
236
+ if (NON_RETRYABLE_RE.test(cause))
237
+ return false;
238
+ return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
126
239
  }
127
240
  /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
128
241
  * brief saturation window can drain before we re-issue the request. */
@@ -165,30 +278,75 @@ thinking = []) {
165
278
  // Sentinel error thrown when the user dismisses a grill-me dialog.
166
279
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
167
280
  export const USER_CANCELLED = '__user_cancelled__';
168
- export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }) {
281
+ /**
282
+ * The `phase` row of WORKER_PROFILES, resolved with this machine's config.
283
+ *
284
+ * Read here rather than at module load so a /task-config change reaches the next
285
+ * child, the same contract childBaseArgs already keeps. Both spawn paths in this
286
+ * file go through it, so the degraded final attempt cannot drift from the ordinary
287
+ * one — the mislabel class runDegradedFinalAttempt's own comment warns about.
288
+ */
289
+ export function phasePolicy() {
290
+ return workerPolicy('phase', {
291
+ commandTimeoutMs: getConfig().requestTimeoutMs,
292
+ streamInactivityMs: getConfig().streamInactivityMs
293
+ });
294
+ }
295
+ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking, commandCeilingMs }) {
169
296
  const invocation = getPiInvocation(childArgs(tools, extensions, thinking), prompt);
170
297
  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;
298
+ const guards = phasePolicy().guards;
299
+ // Null when the user set the ceiling to `off`. Why a phase child needs this at
300
+ // all is the `phase` row's `why` in worker-profiles.ts.
301
+ const cmdWatch = commandWatch(commandCeilingMs ?? guards['command-timeout']);
302
+ const childSignal = cmdWatch ? AbortSignal.any([signal, cmdWatch.signal]) : signal;
303
+ let result;
304
+ try {
305
+ result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, childSignal, {
306
+ mode: 'json-events',
307
+ // A hung model stream reports nothing at all, so without this the
308
+ // phase child waits forever. The kill
309
+ // is reported below as a connection-class cause, which routes it into
310
+ // the retry/backoff path this file already has for a LOUD disconnect.
311
+ streamInactivityMs: guards['stream-stall'],
312
+ ...(guards.stalled === false ?
313
+ {}
314
+ : {
315
+ stall: {
316
+ afterMs: guards.stalled.afterMs,
317
+ probe: guards.stalled.probe
318
+ ?? (() => probeModelEndpoints(discoverModelEndpoints()))
319
+ }
320
+ }),
321
+ onLine,
322
+ onContextUsage,
323
+ ...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
324
+ // ALWAYS wired, never conditional on the caller wanting results:
325
+ // the sink emits a tool-execution-end only when a handler exists,
326
+ // and without that end the command watchdog's timer is never
327
+ // disarmed — every healthy tool call would then look hung.
328
+ onToolResult: r => {
329
+ cmdWatch?.onEnd(r.toolCallId);
330
+ onToolResult?.(r.text, r.isError);
331
+ },
332
+ onToolCall: call => {
333
+ // Before the detectors: a call they let through still needs its
334
+ // clock started.
335
+ cmdWatch?.onStart(call);
336
+ if (!onToolCall)
337
+ return null;
338
+ const hit = onToolCall(call);
339
+ if (hit && !loopHit) {
340
+ loopHit = hit;
341
+ }
342
+ return hit; // propagate to unified runner so it can kill
188
343
  }
189
- return hit; // propagate to unified runner so it can kill
190
- }
191
- });
344
+ });
345
+ }
346
+ finally {
347
+ cmdWatch?.clear();
348
+ }
349
+ const commandKill = cmdWatch?.killed();
192
350
  // Use `||` (not `??`) so an empty string from json-events mode falls
193
351
  // back to raw stdout. Without this, a child that exits 0 but emits no
194
352
  // assistant text (e.g. model API error swallowed in json mode) always
@@ -202,14 +360,16 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
202
360
  ?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
203
361
  return {
204
362
  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,
363
+ // WE killed this child, so its exit status is our own SIGTERM. Report 0
364
+ // and let the named cause carry it. EVERY guard kill must be listed: one
365
+ // omitted here arrives as exit 0 with partial text, and triageChildResult
366
+ // returns it as a successful answer.
367
+ exitCode: result.streamStalled || result.stalled || commandKill ? 0 : result.exitCode,
210
368
  stderr: result.stderr.trim(),
211
369
  loopHit,
212
370
  modelError,
371
+ ...(commandKill ? { commandKill } : {}),
372
+ ...(result.stalled ? { stalled: true } : {}),
213
373
  // A tool call the model wrote as text (wrong dialect) never executed and
214
374
  // sailed past the structured-event guards above; flag it so the wrappers
215
375
  // can re-prompt instead of accepting the unexecuted call. Only meaningful
@@ -346,17 +506,29 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
346
506
  let hint = null;
347
507
  const loopHistory = [];
348
508
  const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
509
+ // From the `phase` row, not from literals here, so the table is the one place
510
+ // that answers "how may this child die". The row resolves to the same
511
+ // DEFAULT_LOOP_DETECTOR / DEFAULT_LOOP_PROGRESS this file used to hard-code.
512
+ const loopGuard = phasePolicy().guards.loop;
513
+ // Watchdog kills, NOT total strikes: a loop restart never saw the
514
+ // bound-your-command hint. Without the halving, three attempts at the default
515
+ // ceiling cost 45 minutes and nothing else bounds this path.
516
+ let hangKills = 0;
349
517
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
350
518
  // A cancel between attempts must not buy another spawn.
351
519
  if (deps.signal.aborted)
352
520
  throw new Error(USER_CANCELLED);
353
- const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
354
- const stall = new StallDetector();
521
+ const detector = loopGuard.detector === false ?
522
+ null
523
+ : new LoopDetector(loopGuard.detector.window, loopGuard.detector.threshold, loopGuard.detector.pathThreshold);
524
+ const stall = loopGuard.progress === false ?
525
+ null
526
+ : new StallDetector(loopGuard.progress.limit, loopGuard.progress.churnFactor);
355
527
  // Arm the churn rule BEFORE the first tool call. pi's stream carries no
356
528
  // context WINDOW, so a detector that waited to be told one would sit at 0,
357
529
  // and the churn rule returns false on a non-positive window. The parent
358
530
  // knows the value at spawn time — say it then, not later.
359
- stall.noteContext(deps.contextWindow ?? 0);
531
+ stall?.noteContext(deps.contextWindow ?? 0);
360
532
  const clock = phaseTimeout(deps.signal, budgetMs);
361
533
  let r;
362
534
  try {
@@ -370,11 +542,12 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
370
542
  // why the parent's value must be supplied at spawn — a
371
543
  // stream that only ever reports 0 leaves the churn rule
372
544
  // permanently disarmed.
373
- stall.noteContext(snapshot.contextWindow);
545
+ stall?.noteContext(snapshot.contextWindow);
374
546
  deps.onContextUsage?.(snapshot);
375
547
  },
376
- onToolCall: call => detector.record(call) ?? stall.record(call),
377
- onToolResult: (text, isError) => stall.noteResult(text, isError)
548
+ commandCeilingMs: commandCeilingForAttempt(phasePolicy().guards['command-timeout'], hangKills),
549
+ onToolCall: call => detector?.record(call) ?? stall?.record(call) ?? null,
550
+ onToolResult: (text, isError) => stall?.noteResult(text, isError)
378
551
  }));
379
552
  }
380
553
  finally {
@@ -404,6 +577,36 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
404
577
  hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
405
578
  continue;
406
579
  }
580
+ // Ordered after the loop rule and before the clock, matching RESTART_ORDER
581
+ // (worker-kill.ts): the loop hint names the offending call and is the more
582
+ // specific thing to tell a re-spawn, and a watchdog kill leaves the phase
583
+ // clock's own flag false, so the two cannot be confused.
584
+ if (r.commandKill) {
585
+ hangKills++;
586
+ if (attempt === MAX_LEAK_RETRIES) {
587
+ throw new CommandTimeoutError(name, r.commandKill);
588
+ }
589
+ deps.logDebug?.(`${name}: ${r.commandKill.toolName} outran `
590
+ + `${Math.round(r.commandKill.timeoutMs / 1000)}s — `
591
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
592
+ // Tracks the TOOLS, not the phase: verify-tooling holds `read,bash`,
593
+ // and a half-written node_modules survives the kill.
594
+ hint = commandTimeoutHint(r.commandKill.toolName, r.commandKill.timeoutMs, {
595
+ ...(r.commandKill.detail ? { commandDetail: r.commandKill.detail } : {}),
596
+ editsMayPersist: /\b(?:bash|edit|write)\b/.test(tools)
597
+ });
598
+ continue;
599
+ }
600
+ // A dead-backend kill spends a strike like the others; guardKillError says
601
+ // when the verdict has been earned.
602
+ const killed = guardKillError(name, r, { finalAttempt: attempt === MAX_LEAK_RETRIES });
603
+ if (killed)
604
+ throw killed;
605
+ if (r.stalled) {
606
+ deps.logDebug?.(`${name}: no output for the stall window and no endpoint answered — `
607
+ + `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
608
+ continue;
609
+ }
407
610
  if (clock.timedOut()) {
408
611
  if (attempt === MAX_LEAK_RETRIES) {
409
612
  throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
@@ -501,6 +704,12 @@ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
501
704
  finally {
502
705
  clock.cleanup();
503
706
  }
707
+ // BEFORE the exit-code test, not inside it: a guard kill reports exit 0, so
708
+ // asking afterwards would already have returned the truncated text as this
709
+ // phase's deliverable.
710
+ const killed = guardKillError(name, r);
711
+ if (killed)
712
+ throw killed;
504
713
  if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
505
714
  // A wall-clock kill is NOT a loop. Without this check a child that outran
506
715
  // its budget is reported as "loop budget exhausted", carrying a loop
@@ -5,13 +5,36 @@
5
5
  import { updateTaskFrontMatter } from './task-io.js';
6
6
  import { flashTerminalWidget } from './widget.js';
7
7
  import { publishLifecycleNotice } from '../remote/bridge.js';
8
- import { LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
8
+ import { BackendDownError, CommandTimeoutError, LoopExhaustedError, LeakedToolCallError, ModelError, USER_CANCELLED } from './child-runner.js';
9
9
  // ─── Classifier ──────────────────────────────────────────────────────────────
10
10
  export function classifyFailure(err, aborted) {
11
11
  const msg = err instanceof Error ? err.message : String(err);
12
12
  if (aborted || msg === USER_CANCELLED) {
13
13
  return { state: 'cancelled', notify: 'cancelled.', level: 'warning' };
14
14
  }
15
+ // Classified by TYPE, above the message-sniffing branch below: this is the one
16
+ // case where the probe positively established the endpoint did not answer, and
17
+ // its message names no errno for that branch to match.
18
+ if (err instanceof BackendDownError) {
19
+ return {
20
+ state: 'failed',
21
+ reason: `model_unreachable: ${err.message}`,
22
+ flash: 'model_unreachable',
23
+ notify: 'failed: model unreachable — restart the model, then resume.',
24
+ level: 'error'
25
+ };
26
+ }
27
+ // The fix is in the SPEC, not the model, so the notify says which command.
28
+ if (err instanceof CommandTimeoutError) {
29
+ return {
30
+ state: 'failed',
31
+ reason: err.message.slice(0, 200),
32
+ flash: 'command_timeout',
33
+ notify: `failed: \`${err.kill.toolName}\` never returned on any attempt. `
34
+ + `Resume to bound it in VERIFY.`,
35
+ level: 'error'
36
+ };
37
+ }
15
38
  if (err instanceof LoopExhaustedError) {
16
39
  return {
17
40
  state: 'failed',
@@ -0,0 +1,26 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
3
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
4
+ export declare function armImplementationGuard(opts: {
5
+ oneShot: boolean;
6
+ }): void;
7
+ export declare function disarmImplementationGuard(): void;
8
+ /** @internal Test seam: is a turn currently guarded? */
9
+ export declare function implementationGuardArmed(): boolean;
10
+ /**
11
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
12
+ * about an attempt that does not exist here.
13
+ *
14
+ * It claims nothing about the call's RESULT, which this hook fires too early to
15
+ * see, and it does not offer "change the call" — that is an escape, not advice:
16
+ * one altered byte is a new key and a clean slate on both counters.
17
+ */
18
+ export declare function blockedCallReason(toolName: string, count: number): string;
19
+ /** The reason on the final block, which also ends the turn. */
20
+ export declare function terminalCallReason(): string;
21
+ export declare function consumeGuardTermination(): boolean;
22
+ /**
23
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
24
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
25
+ */
26
+ export declare function registerImplementationGuards(pi: ExtensionAPI): void;
@@ -0,0 +1,177 @@
1
+ import { LoopDetector, loopKey, LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from './loop-detector.js';
2
+ /**
3
+ * Runaway guard for the IMPLEMENTATION TURN — the one model surface with none.
4
+ *
5
+ * MEASURED: one turn ran 5h16m and 6,760 tool calls, alternating two
6
+ * byte-identical bash commands 3,300 times each with its output frozen and zero
7
+ * edits. The command watchdog is per call (each took ~2.5s), the stream was never
8
+ * silent, and MAX_COMPACTION_RESUMES counts only compactions that PARK at idle,
9
+ * while all 18 of these were inside the turn. The two detectors are built only in
10
+ * the CHILD spawn paths, and this turn runs in the user's own session.
11
+ *
12
+ * IT BLOCKS RATHER THAN KILLS because there is no re-spawn here — the argument
13
+ * single-read-extension.ts already makes: "detect-and-kill only re-spawns a model
14
+ * that deterministically re-thrashes". pi's ctx.abort() would also empty the
15
+ * queued-message list into the user's editor. That also raises the bar on false
16
+ * positives: a `gate` child killed by mistake costs one attempt of three, this
17
+ * costs the user their turn.
18
+ *
19
+ * WHY PROGRESS IS READ OFF THE CALL, NOT THE RESULT. The gate profile pairs its
20
+ * LoopDetector with a StallDetector, which judges results. That cannot work here:
21
+ * pi's edit tool returns the constant `Successfully replaced N block(s) in <path>.`
22
+ * and puts the diff in `details`, not `content`, so every real edit to one file is
23
+ * byte-identical result text and scores as dead ground. An edit's ARGUMENTS carry
24
+ * the progress its result throws away, so an edit is what resets the window.
25
+ *
26
+ * Known blind spots, all deliberate: a bash-driven mutation (`sed -i`, `>`,
27
+ * `git apply`) does not reset; one varying `write` per iteration buys unlimited
28
+ * immunity; a repeat cycle of LOOP_WINDOW/LOOP_THRESHOLD or longer never fills the
29
+ * window; polling a booting server with an identical curl trips at five; and
30
+ * schema-invalid calls never reach this hook at all, since pi validates first.
31
+ */
32
+ /** Tool names that mutate the tree. pi ships exactly seven core tools, and
33
+ * pi-task's own four (pi-worker, -search, -fetch, -docs) are all read-only. */
34
+ const MUTATING_TOOLS = new Set(['edit', 'write']);
35
+ /**
36
+ * Path-revisit is OFF for both detectors (that is the Infinity). MEASURED: at the
37
+ * default threshold, six DISTINCT edits to one file trip the path rule, because
38
+ * an edit names a `file_path` and no `limit`, so the first one sets the
39
+ * high-water mark and every later one scores as already-covered ground. Six edits
40
+ * to one file is the most ordinary thing an implementation turn does. mx5
41
+ * TASK_0002 is the same lesson from the other side: the rule killed an enforce
42
+ * child that was editing one file as its job.
43
+ */
44
+ function freshDetector() {
45
+ return new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD, Number.POSITIVE_INFINITY);
46
+ }
47
+ /** The armed turn's state, or null outside one. One slot: one task runs at a time. */
48
+ let armed = null;
49
+ /** Built, never spread from the previous state: a leaked `terminating` would
50
+ * block every call for the rest of an awaited run. */
51
+ function freshArmedState(oneShot) {
52
+ return {
53
+ loop: freshDetector(),
54
+ edits: freshDetector(),
55
+ strikes: new Map(),
56
+ terminating: false,
57
+ oneShot
58
+ };
59
+ }
60
+ /** `oneShot` mirrors the impl widget's split: fire-and-forget lets the settle
61
+ * event disarm; an awaited run spans resume/steer turns and disarms in its finally. */
62
+ export function armImplementationGuard(opts) {
63
+ armed = freshArmedState(opts.oneShot);
64
+ }
65
+ export function disarmImplementationGuard() {
66
+ armed = null;
67
+ }
68
+ /** @internal Test seam: is a turn currently guarded? */
69
+ export function implementationGuardArmed() {
70
+ return armed !== null;
71
+ }
72
+ /**
73
+ * Present tense, unlike `formatLoopHint`, which addresses a re-spawned child
74
+ * about an attempt that does not exist here.
75
+ *
76
+ * It claims nothing about the call's RESULT, which this hook fires too early to
77
+ * see, and it does not offer "change the call" — that is an escape, not advice:
78
+ * one altered byte is a new key and a clean slate on both counters.
79
+ */
80
+ export function blockedCallReason(toolName, count) {
81
+ return (`Blocked: this is the ${count}th identical ${toolName} call in this turn. `
82
+ + `Use what you already have, or do something different, then continue the task.`);
83
+ }
84
+ /** The reason on the final block, which also ends the turn. */
85
+ export function terminalCallReason() {
86
+ return (`Blocked: this turn repeated one call past every warning, so it is being stopped `
87
+ + `here. Nothing further will run.`);
88
+ }
89
+ /**
90
+ * One-shot: the guard ended a turn, and nothing in the session state says so.
91
+ *
92
+ * `terminate` lets the agent loop finish normally — the last assistant message
93
+ * keeps `stopReason: "toolUse"`, so `classifyTurnEnd` reads `'stop'` and the run
94
+ * reports a clean finish over work that was cut off mid-task. Verified against a
95
+ * live model: a real guard-terminated turn ends exactly that way. Same shape as
96
+ * `consumeWatchdogAbort`, and consumed for the same reason — one reader, then it
97
+ * is gone.
98
+ */
99
+ let terminatedTurn = false;
100
+ export function consumeGuardTermination() {
101
+ const hit = terminatedTurn;
102
+ terminatedTurn = false;
103
+ return hit;
104
+ }
105
+ /**
106
+ * Inert until armed. Registering ANY `tool_call` handler switches on pi's
107
+ * `beforeToolCall` for every call in the session, so the armed check comes first.
108
+ */
109
+ export function registerImplementationGuards(pi) {
110
+ pi.on('tool_call', event => {
111
+ const state = armed;
112
+ if (!state)
113
+ return;
114
+ try {
115
+ // Every call, whatever it is: pi terminates only when EVERY finalized
116
+ // result in the batch carries the flag (agent-loop.js
117
+ // shouldTerminateToolBatch). The batch that trips it has already
118
+ // finalized its earlier calls without it and so survives; the next one
119
+ // ends. One batch, and it is the only bound this path has.
120
+ if (state.terminating) {
121
+ return { block: true, terminate: true, reason: terminalCallReason() };
122
+ }
123
+ const call = { name: event.toolName, args: event.input };
124
+ const mutating = MUTATING_TOOLS.has(event.toolName);
125
+ const hit = mutating ? state.edits.record(call) : state.loop.record(call);
126
+ if (!hit) {
127
+ if (mutating) {
128
+ state.loop = freshDetector();
129
+ // Strikes go with the window. Keeping them made a LATER episode
130
+ // terminate on its first hit, skipping both warnings, because an
131
+ // earlier one had part-spent the budget. MEASURED over 494 real
132
+ // turns: this loses no catch — the incident still ends at call
133
+ // 173 of 6,760, both live-model loops still end — and drops one
134
+ // termination of a turn that was editing between episodes.
135
+ // A determined model is still bounded: three hits inside ONE
136
+ // episode terminate, which is the runaway shape (it makes no
137
+ // edits at all).
138
+ state.strikes.clear();
139
+ }
140
+ return;
141
+ }
142
+ const key = loopKey(call);
143
+ const strikes = (state.strikes.get(key) ?? 0) + 1;
144
+ state.strikes.set(key, strikes);
145
+ // Blocking alone does not stop a determined model: nothing prevents the
146
+ // next identical call.
147
+ if (strikes > MAX_LOOP_RESTARTS) {
148
+ state.terminating = true;
149
+ terminatedTurn = true;
150
+ return { block: true, terminate: true, reason: terminalCallReason() };
151
+ }
152
+ return { block: true, reason: blockedCallReason(event.toolName, hit.count) };
153
+ }
154
+ catch {
155
+ // pi does not guard this hook, and a throw here would block a
156
+ // legitimate call. A broken guard must cost nothing.
157
+ return;
158
+ }
159
+ });
160
+ // NOT `agent_end`, which fires again for every auto-retry, every threshold
161
+ // compaction and every queued message — pi drives those with `agent.continue()`,
162
+ // each a fresh agent loop. The measured runaway compacted 18 times INSIDE its
163
+ // turn, so a one-shot disarm on agent_end would have retired the guard after the
164
+ // first ~375 of its 6,760 calls. `agent_settled` is the boundary that means what
165
+ // this needs: no retry, compaction or queued continuation left to run.
166
+ pi.on('agent_settled', () => {
167
+ if (!armed)
168
+ return;
169
+ if (armed.oneShot)
170
+ disarmImplementationGuard();
171
+ // An awaited run spans resume and steer turns. Counters are per TURN, so a
172
+ // fresh one starts clean rather than inheriting the last one's strikes.
173
+ else
174
+ armed = freshArmedState(false);
175
+ });
176
+ pi.on('session_shutdown', disarmImplementationGuard);
177
+ }
@@ -96,6 +96,8 @@ export interface SteerWatchdogDeps {
96
96
  export interface ImplementationTurnDeps {
97
97
  /** The live session entries — the only thing the classifier reads. */
98
98
  entries: () => ReadonlyArray<SessionEntryLike>;
99
+ /** Test seam over the module-level one-shot; the real reader is the default. */
100
+ consumeGuardTermination?: () => boolean;
99
101
  /** Queue a follow-up user turn on the (idle) session. */
100
102
  send: (text: string) => Promise<void>;
101
103
  /** Wait for the session to go idle again. */
@@ -139,6 +141,9 @@ export declare const CONTINUE_AFTER_COMPACTION: string;
139
141
  * lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
140
142
  */
141
143
  export declare const MAX_COMPACTION_RESUMES = 20;
144
+ /** How a guard-stopped turn is reported. Named so a caller can tell it from a
145
+ * provider error: the fix is a different task, not a retry of this one. */
146
+ export declare const GUARD_TERMINATED = "the runaway guard stopped this turn: one tool call was repeated past every warning";
142
147
  /**
143
148
  * Resume an implementation turn that went idle at a threshold-compaction boundary.
144
149
  * The runtime compacts and parks at idle without auto-continuing; we send a