@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.
- package/dist/task/child-runner.d.ts +52 -0
- package/dist/task/child-runner.js +122 -1
- package/package.json +1 -1
|
@@ -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
|
|
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.
|
|
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",
|