@mjasnikovs/pi-task 0.38.6 → 0.38.8

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.
@@ -25,7 +25,7 @@ import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from
25
25
  import { armCancelListener, disarmCancelListener } from './cancel-input.js';
26
26
  import { beginRun, endRun } from './mid-run-input.js';
27
27
  import { reportDroppedInput } from './dropped-input.js';
28
- import { refineExistingFilesBlock } from './phases.js';
28
+ import { refineExistingFilesBlock, SINGLE_READ_EXTENSION_PATH } from './phases.js';
29
29
  import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
30
30
  import { pushNotify } from '../remote/push.js';
31
31
  import { startAutoLoader } from './widget.js';
@@ -1079,6 +1079,13 @@ function defaultDeps(ctx, cwd, signal, title) {
1079
1079
  cwd,
1080
1080
  taskId: '',
1081
1081
  signal,
1082
+ // IN-RUN thrash guard for the planning children (mx5-n 2026-08-14: a
1083
+ // decompose child re-read DESIGN/marketplace.html until it filled a
1084
+ // 120k window, and ran 16m23s without returning). Every planning child
1085
+ // gets its source doc INLINED in its prompt, so a second read of a file
1086
+ // it has already opened can only be thrash — which makes the read-once
1087
+ // block safe here in a way it is not for a phase that must explore.
1088
+ childExtensions: [SINGLE_READ_EXTENSION_PATH],
1082
1089
  onChildOutput: (line) => {
1083
1090
  lastLine = line;
1084
1091
  },
@@ -10,6 +10,40 @@ import type { DebugLine } from './debug-log.js';
10
10
  export declare const LOOP_WINDOW = 20;
11
11
  export declare const LOOP_THRESHOLD = 5;
12
12
  export declare const MAX_LOOP_RESTARTS = 2;
13
+ /**
14
+ * Hard wall-clock bound on ONE spawn of a phase child.
15
+ *
16
+ * The loop detector above only sees IDENTICAL repeated calls; a child that
17
+ * re-reads the same design file at varying offsets slips past it and, with pi
18
+ * compacting its context whenever the window fills, never exits on its own.
19
+ * mx5-n 2026-08-14 is the observed case: a decompose child ran 16m23s at
20
+ * 117,370 of a 120,064-token window, adding ~56k tokens of tool output per
21
+ * minute, and had to be killed by hand. `streamInactivityMs` cannot catch it —
22
+ * that guard fires on SILENCE and this child was the opposite of silent.
23
+ *
24
+ * Sized against measured HEALTHY planning children on the same local 27B
25
+ * backend, which is the slowest thing we run: requirement extraction 54s,
26
+ * artifact closure 47s, decompose 89s (22 titles), coverage 17s, and a whole
27
+ * plan phase (clarify + two extractions + decompose) 321s end to end. Ten
28
+ * minutes is 3-6x the slowest of those and well under the runaway, so it ends
29
+ * the pathology without ever trimming honest work. Deliberately far above
30
+ * RESEARCH_WORKER_TIMEOUT_MS (240s): a research worker answers one question,
31
+ * a planning child reasons over the whole design doc.
32
+ */
33
+ export declare const PHASE_CHILD_TIMEOUT_MS = 600000;
34
+ /**
35
+ * Restart hint after a phase child burns its whole wall-clock budget. It
36
+ * diagnoses over-exploration, which is what the cap actually catches — the same
37
+ * job WORKER_TIMEOUT_HINT does for research workers.
38
+ */
39
+ export declare const PHASE_TIMEOUT_HINT: string;
40
+ /** Thrown when a phase child spends its whole restart budget hitting the cap. */
41
+ export declare class PhaseTimeoutError extends Error {
42
+ readonly childName: string;
43
+ readonly budgetMs: number;
44
+ readonly attempts: number;
45
+ constructor(childName: string, budgetMs: number, attempts: number);
46
+ }
13
47
  export declare function isConnectionError(cause: string): boolean;
14
48
  /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
15
49
  * brief saturation window can drain before we re-issue the request. */
@@ -24,14 +58,16 @@ export interface PhaseRunResult {
24
58
  /** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
25
59
  modelError?: string;
26
60
  }
27
- export declare function childArgs(tools: string): string[];
61
+ export declare function childArgs(tools: string, extensions?: readonly string[]): string[];
28
62
  export declare const USER_CANCELLED = "__user_cancelled__";
29
63
  /**
30
64
  * Run a child pi process with JSON event-stream output, loop detection, and
31
65
  * context-usage tracking. This is the typed convenience wrapper used by
32
66
  * phase-level code.
33
67
  */
34
- export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn): Promise<PhaseRunResult>;
68
+ export declare function runChild(cwd: string, tools: string, prompt: string, signal: AbortSignal, onLine?: (line: string) => void, onContextUsage?: (snapshot: ContextSnapshot) => void, onToolCall?: (call: ToolCall) => LoopHit | null, spawnFn?: SpawnFn,
69
+ /** Internal `-e` extension paths for in-run guards (see childArgs). */
70
+ extensions?: readonly string[]): Promise<PhaseRunResult>;
35
71
  interface PhaseDeps {
36
72
  cwd: string;
37
73
  taskId: string;
@@ -45,6 +81,24 @@ interface PhaseDeps {
45
81
  */
46
82
  recordSubStep?: (label: string, ms: number) => void;
47
83
  spawn?: SpawnFn;
84
+ /**
85
+ * Internal `-e` extension paths loaded into this child for IN-RUN guards.
86
+ *
87
+ * The point of a guard that runs inside the child is that it does not have
88
+ * to kill it: pi turns a `tool_call` handler's `{block, reason}` into an
89
+ * error tool result, so the model reads the reason as its own tool output
90
+ * and continues with its context intact. The host's only alternative is to
91
+ * kill and re-spawn from nothing, which just re-runs a model that
92
+ * deterministically re-thrashes (workers/single-read-guard.ts).
93
+ */
94
+ childExtensions?: readonly string[];
95
+ /**
96
+ * Wall-clock budget for ONE spawn of this child, in ms. Defaults to
97
+ * PHASE_CHILD_TIMEOUT_MS; `0` disables the cap. Mirrors runWorker's
98
+ * `timeoutMs` input, which is the same backstop one layer down
99
+ * (workers/pi-worker-core.ts). Tests inject a short budget.
100
+ */
101
+ timeoutMs?: number;
48
102
  /**
49
103
  * Write a timestamped line to the per-task debug log. Fire-and-forget, and
50
104
  * UNSET entirely when the trail is off — so a caller must keep the `?.` and
@@ -68,6 +122,17 @@ export type { PhaseDeps };
68
122
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
69
123
  * Empty completions and connection-class model errors share that same budget —
70
124
  * see triageChildResult, which decides every one of those cases.
125
+ *
126
+ * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
127
+ * /task-auto planning child goes through (clarify, decompose, coverage,
128
+ * contract-extract) and until mx5-n 2026-08-14 it had neither:
129
+ * • a LoopDetector, so an identical repeated tool call is killed and
130
+ * re-prompted instead of being allowed to fill the context window;
131
+ * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
132
+ * detector cannot see — the shape that actually cost us a 16-minute
133
+ * decompose child that was never going to return.
134
+ * Both are checked BEFORE the triage ladder: we killed the child, so its exit
135
+ * status describes our SIGTERM and says nothing about its verdict.
71
136
  */
72
137
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
73
138
  export declare function formatLoopHint(hit: LoopHit): string;
@@ -21,6 +21,86 @@ export const LOOP_WINDOW = 20;
21
21
  export const LOOP_THRESHOLD = 5;
22
22
  export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
23
23
  // MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
24
+ // ─── Phase-child wall-clock cap ──────────────────────────────────────────────
25
+ /**
26
+ * Hard wall-clock bound on ONE spawn of a phase child.
27
+ *
28
+ * The loop detector above only sees IDENTICAL repeated calls; a child that
29
+ * re-reads the same design file at varying offsets slips past it and, with pi
30
+ * compacting its context whenever the window fills, never exits on its own.
31
+ * mx5-n 2026-08-14 is the observed case: a decompose child ran 16m23s at
32
+ * 117,370 of a 120,064-token window, adding ~56k tokens of tool output per
33
+ * minute, and had to be killed by hand. `streamInactivityMs` cannot catch it —
34
+ * that guard fires on SILENCE and this child was the opposite of silent.
35
+ *
36
+ * Sized against measured HEALTHY planning children on the same local 27B
37
+ * backend, which is the slowest thing we run: requirement extraction 54s,
38
+ * artifact closure 47s, decompose 89s (22 titles), coverage 17s, and a whole
39
+ * plan phase (clarify + two extractions + decompose) 321s end to end. Ten
40
+ * minutes is 3-6x the slowest of those and well under the runaway, so it ends
41
+ * the pathology without ever trimming honest work. Deliberately far above
42
+ * RESEARCH_WORKER_TIMEOUT_MS (240s): a research worker answers one question,
43
+ * a planning child reasons over the whole design doc.
44
+ */
45
+ export const PHASE_CHILD_TIMEOUT_MS = 600_000;
46
+ /**
47
+ * Restart hint after a phase child burns its whole wall-clock budget. It
48
+ * diagnoses over-exploration, which is what the cap actually catches — the same
49
+ * job WORKER_TIMEOUT_HINT does for research workers.
50
+ */
51
+ export const PHASE_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
52
+ + 'were re-reading source material you had already seen. Read each file AT '
53
+ + 'MOST ONCE, then write your answer from what you have. Do not re-open a '
54
+ + 'file you have already read.]';
55
+ /**
56
+ * Combine the caller's abort signal with a wall-clock timer into one signal,
57
+ * keeping the two causes apart: `timedOut()` is true only when the timer fired,
58
+ * never when the user cancelled — so a cap can restart the child while a cancel
59
+ * still ends the run. `ms <= 0` disables the timer entirely.
60
+ *
61
+ * (workers/pi-worker-core.ts has the same shape for research workers. It is not
62
+ * shared because that module imports FROM this one; a common home for it would
63
+ * be worth it if a third caller ever appears.)
64
+ */
65
+ function phaseTimeout(external, ms) {
66
+ const ctrl = new AbortController();
67
+ let firedByTimer = false;
68
+ const armed = ms > 0 && Number.isFinite(ms);
69
+ const timer = armed ?
70
+ setTimeout(() => {
71
+ firedByTimer = true;
72
+ ctrl.abort();
73
+ }, ms)
74
+ : undefined;
75
+ const onExternal = () => ctrl.abort();
76
+ if (external.aborted)
77
+ ctrl.abort();
78
+ else
79
+ external.addEventListener('abort', onExternal, { once: true });
80
+ return {
81
+ signal: ctrl.signal,
82
+ timedOut: () => firedByTimer,
83
+ cleanup: () => {
84
+ if (timer)
85
+ clearTimeout(timer);
86
+ external.removeEventListener('abort', onExternal);
87
+ }
88
+ };
89
+ }
90
+ /** Thrown when a phase child spends its whole restart budget hitting the cap. */
91
+ export class PhaseTimeoutError extends Error {
92
+ childName;
93
+ budgetMs;
94
+ attempts;
95
+ constructor(childName, budgetMs, attempts) {
96
+ super(`${childName} child exceeded its ${Math.round(budgetMs / 1000)}s budget on all `
97
+ + `${attempts} attempt(s) — it never stopped working long enough to answer`);
98
+ this.childName = childName;
99
+ this.budgetMs = budgetMs;
100
+ this.attempts = attempts;
101
+ this.name = 'PhaseTimeoutError';
102
+ }
103
+ }
24
104
  // ─── Connection-error retry ──────────────────────────────────────────────────
25
105
  /**
26
106
  * A connection-class model error is transient: a single dropped fetch to a live
@@ -47,7 +127,7 @@ export function connectionRetryBackoffMs(attempt) {
47
127
  }
48
128
  const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
49
129
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
50
- export function childArgs(tools) {
130
+ export function childArgs(tools, extensions = []) {
51
131
  // `--mode json` puts the child into the structured event stream the
52
132
  // unified runner parses in `mode: 'json-events'`. Without it the child
53
133
  // emits plain text, every line fails JSON.parse, finalText stays empty,
@@ -62,8 +142,13 @@ export function childArgs(tools) {
62
142
  // The prompt is NOT an argv element: it goes to the child over stdin (see
63
143
  // runChild below / getPiInvocation), so a large inlined-design prompt can't
64
144
  // overflow the OS command-line limit (Windows `spawn ENAMETOOLONG`).
145
+ //
146
+ // `extensions` are internal `-e` loads for in-run guards (the caller supplies
147
+ // the path). A no-tools child cannot make a tool call, so it never carries
148
+ // one — the guards all hang off pi's `tool_call` hook.
65
149
  const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
66
- return [...childBaseArgs(), '--mode', 'json', ...toolFlags];
150
+ const internal = tools === '' ? [] : extensions;
151
+ return [...childBaseArgs(internal), '--mode', 'json', ...toolFlags];
67
152
  }
68
153
  // Sentinel error thrown when the user dismisses a grill-me dialog.
69
154
  // Defined here (not in failure-classifier.ts) to avoid circular dependency.
@@ -74,8 +159,10 @@ export const USER_CANCELLED = '__user_cancelled__';
74
159
  * context-usage tracking. This is the typed convenience wrapper used by
75
160
  * phase-level code.
76
161
  */
77
- export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn) {
78
- const invocation = getPiInvocation(childArgs(tools), prompt);
162
+ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn,
163
+ /** Internal `-e` extension paths for in-run guards (see childArgs). */
164
+ extensions) {
165
+ const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
79
166
  let loopHit;
80
167
  const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
81
168
  mode: 'json-events',
@@ -188,11 +275,52 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
188
275
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
189
276
  * Empty completions and connection-class model errors share that same budget —
190
277
  * see triageChildResult, which decides every one of those cases.
278
+ *
279
+ * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
280
+ * /task-auto planning child goes through (clarify, decompose, coverage,
281
+ * contract-extract) and until mx5-n 2026-08-14 it had neither:
282
+ * • a LoopDetector, so an identical repeated tool call is killed and
283
+ * re-prompted instead of being allowed to fill the context window;
284
+ * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
285
+ * detector cannot see — the shape that actually cost us a 16-minute
286
+ * decompose child that was never going to return.
287
+ * Both are checked BEFORE the triage ladder: we killed the child, so its exit
288
+ * status describes our SIGTERM and says nothing about its verdict.
191
289
  */
192
290
  export async function runPhaseChild(deps, name, tools, prompt) {
193
291
  let hint = null;
292
+ const loopHistory = [];
293
+ const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
194
294
  for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
195
- const r = await runChild(deps.cwd, tools, prependHint(hint, prompt), deps.signal, deps.onChildOutput, deps.onContextUsage, undefined, deps.spawn);
295
+ const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
296
+ const clock = phaseTimeout(deps.signal, budgetMs);
297
+ let r;
298
+ try {
299
+ r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn, deps.childExtensions);
300
+ }
301
+ finally {
302
+ clock.cleanup();
303
+ }
304
+ // A user cancel must not be mistaken for either guard.
305
+ if (deps.signal.aborted)
306
+ throw new Error(USER_CANCELLED);
307
+ if (r.loopHit) {
308
+ loopHistory.push(r.loopHit);
309
+ if (attempt === MAX_LEAK_RETRIES)
310
+ throw new LoopExhaustedError(name, loopHistory);
311
+ deps.logDebug?.(`${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
312
+ hint = formatLoopHint(r.loopHit);
313
+ continue;
314
+ }
315
+ if (clock.timedOut()) {
316
+ if (attempt === MAX_LEAK_RETRIES) {
317
+ throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
318
+ }
319
+ deps.logDebug?.(`${name}: exceeded its ${Math.round(budgetMs / 1000)}s budget — `
320
+ + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
321
+ hint = PHASE_TIMEOUT_HINT;
322
+ continue;
323
+ }
196
324
  const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, 'retry');
197
325
  if (step.done)
198
326
  return step.text;
@@ -111,6 +111,15 @@ export declare function searchConfigured(getEnv?: (k: string) => string | undefi
111
111
  /** Extra prompt block for the APIS worker when search is available — trigger-framed
112
112
  * (the validated shape for getting a local model to actually reach for search). */
113
113
  export declare const RESEARCH_SEARCH_HINT: string;
114
+ /**
115
+ * In-process guards loaded into the TOOLING worker only: block a re-read of any
116
+ * file already read, and block any byte-identical grep/find/ls repeat, feeding
117
+ * the model "you already have this, answer now" instead of letting it re-run.
118
+ * TOOLING reads each file once and never needs an identical search twice in any
119
+ * healthy recorded run, so neither rule has a legitimate false positive here.
120
+ * See single-read-guard.ts.
121
+ */
122
+ export declare const SINGLE_READ_EXTENSION_PATH: string;
114
123
  /**
115
124
  * The TOOLING worker only needs to know which verification commands the task
116
125
  * cares about — never the per-file edit list. Big refined prompts embed a long
@@ -332,7 +332,7 @@ export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for e
332
332
  * healthy recorded run, so neither rule has a legitimate false positive here.
333
333
  * See single-read-guard.ts.
334
334
  */
335
- const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
335
+ export const SINGLE_READ_EXTENSION_PATH = fileURLToPath(new URL('../workers/single-read-extension.js', import.meta.url));
336
336
  /**
337
337
  * Task-file heading under which a research worker's validated output is cached.
338
338
  * A resumed research phase reads these to skip workers that already succeeded,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.6",
3
+ "version": "0.38.8",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",