@mjasnikovs/pi-task 0.38.6 → 0.38.7

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.
@@ -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. */
@@ -45,6 +79,13 @@ interface PhaseDeps {
45
79
  */
46
80
  recordSubStep?: (label: string, ms: number) => void;
47
81
  spawn?: SpawnFn;
82
+ /**
83
+ * Wall-clock budget for ONE spawn of this child, in ms. Defaults to
84
+ * PHASE_CHILD_TIMEOUT_MS; `0` disables the cap. Mirrors runWorker's
85
+ * `timeoutMs` input, which is the same backstop one layer down
86
+ * (workers/pi-worker-core.ts). Tests inject a short budget.
87
+ */
88
+ timeoutMs?: number;
48
89
  /**
49
90
  * Write a timestamped line to the per-task debug log. Fire-and-forget, and
50
91
  * UNSET entirely when the trail is off — so a caller must keep the `?.` and
@@ -68,6 +109,17 @@ export type { PhaseDeps };
68
109
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
69
110
  * Empty completions and connection-class model errors share that same budget —
70
111
  * see triageChildResult, which decides every one of those cases.
112
+ *
113
+ * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
114
+ * /task-auto planning child goes through (clarify, decompose, coverage,
115
+ * contract-extract) and until mx5-n 2026-08-14 it had neither:
116
+ * • a LoopDetector, so an identical repeated tool call is killed and
117
+ * re-prompted instead of being allowed to fill the context window;
118
+ * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
119
+ * detector cannot see — the shape that actually cost us a 16-minute
120
+ * decompose child that was never going to return.
121
+ * Both are checked BEFORE the triage ladder: we killed the child, so its exit
122
+ * status describes our SIGTERM and says nothing about its verdict.
71
123
  */
72
124
  export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
73
125
  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
@@ -188,11 +268,52 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
188
268
  * leaking, throw LeakedToolCallError rather than returning the unexecuted call.
189
269
  * Empty completions and connection-class model errors share that same budget —
190
270
  * see triageChildResult, which decides every one of those cases.
271
+ *
272
+ * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
273
+ * /task-auto planning child goes through (clarify, decompose, coverage,
274
+ * contract-extract) and until mx5-n 2026-08-14 it had neither:
275
+ * • a LoopDetector, so an identical repeated tool call is killed and
276
+ * re-prompted instead of being allowed to fill the context window;
277
+ * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
278
+ * detector cannot see — the shape that actually cost us a 16-minute
279
+ * decompose child that was never going to return.
280
+ * Both are checked BEFORE the triage ladder: we killed the child, so its exit
281
+ * status describes our SIGTERM and says nothing about its verdict.
191
282
  */
192
283
  export async function runPhaseChild(deps, name, tools, prompt) {
193
284
  let hint = null;
285
+ const loopHistory = [];
286
+ const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
194
287
  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);
288
+ const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
289
+ const clock = phaseTimeout(deps.signal, budgetMs);
290
+ let r;
291
+ try {
292
+ r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, deps.onContextUsage, call => detector.record(call), deps.spawn);
293
+ }
294
+ finally {
295
+ clock.cleanup();
296
+ }
297
+ // A user cancel must not be mistaken for either guard.
298
+ if (deps.signal.aborted)
299
+ throw new Error(USER_CANCELLED);
300
+ if (r.loopHit) {
301
+ loopHistory.push(r.loopHit);
302
+ if (attempt === MAX_LEAK_RETRIES)
303
+ throw new LoopExhaustedError(name, loopHistory);
304
+ deps.logDebug?.(`${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
305
+ hint = formatLoopHint(r.loopHit);
306
+ continue;
307
+ }
308
+ if (clock.timedOut()) {
309
+ if (attempt === MAX_LEAK_RETRIES) {
310
+ throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
311
+ }
312
+ deps.logDebug?.(`${name}: exceeded its ${Math.round(budgetMs / 1000)}s budget — `
313
+ + `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
314
+ hint = PHASE_TIMEOUT_HINT;
315
+ continue;
316
+ }
196
317
  const step = await triageChildResult(deps, name, r, attempt, MAX_LEAK_RETRIES, 'retry');
197
318
  if (step.done)
198
319
  return step.text;
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.7",
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",