@mjasnikovs/pi-task 0.38.10 → 0.38.12
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/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +93 -116
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +56 -25
- package/dist/task/child-runner.js +65 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +38 -432
- package/dist/task/final-gate.js +105 -1213
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +70 -297
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -11,26 +11,30 @@ export declare const LOOP_WINDOW = 20;
|
|
|
11
11
|
export declare const LOOP_THRESHOLD = 5;
|
|
12
12
|
export declare const MAX_LOOP_RESTARTS = 2;
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
16
|
+
* It used to default to 600_000, sized against measured HEALTHY planning
|
|
17
|
+
* children on one local 27B backend (decompose 89s, whole plan phase 321s) on
|
|
18
|
+
* the reasoning that ten minutes was a 3-6x margin over honest work.
|
|
23
19
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* a
|
|
20
|
+
* That premise was measured and is false. Replaying ONE captured auto-decompose
|
|
21
|
+
* request against the same backend with reasoning ON, n=10, everything else
|
|
22
|
+
* byte-identical (2026-08-17): all ten answered correctly with 26-42 titles, and
|
|
23
|
+
* every one took 610-927s. The cap would have killed 10 out of 10 GOOD runs and
|
|
24
|
+
* then failed the phase with PhaseTimeoutError. The number was not measuring the
|
|
25
|
+
* pathology, it was measuring one model's speed on one day.
|
|
26
|
+
*
|
|
27
|
+
* The runaway it was there to catch — a decompose child that ran 16m23s at
|
|
28
|
+
* 117,370 of a 120,064-token window, forward-paging past the loop detector — is
|
|
29
|
+
* now caught by StallDetector (stall-detector.ts), which bounds NON-PROGRESS and
|
|
30
|
+
* CONTEXT CHURN instead of elapsed seconds. Both of those are properties of the
|
|
31
|
+
* pathology, so neither has to be re-tuned for a slower model or a bigger repo.
|
|
32
|
+
*
|
|
33
|
+
* The value and the plumbing stay for a caller that genuinely wants a hard stop
|
|
34
|
+
* (tests inject a short one), but nothing sets it in production. Pass
|
|
35
|
+
* `timeoutMs` explicitly to arm it.
|
|
32
36
|
*/
|
|
33
|
-
export declare const PHASE_CHILD_TIMEOUT_MS =
|
|
37
|
+
export declare const PHASE_CHILD_TIMEOUT_MS = 0;
|
|
34
38
|
/**
|
|
35
39
|
* Restart hint after a phase child burns its whole wall-clock budget. It
|
|
36
40
|
* diagnoses over-exploration, which is what the cap actually catches — the same
|
|
@@ -67,7 +71,13 @@ export declare const USER_CANCELLED = "__user_cancelled__";
|
|
|
67
71
|
*/
|
|
68
72
|
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
73
|
/** Internal `-e` extension paths for in-run guards (see childArgs). */
|
|
70
|
-
extensions?: readonly string[]
|
|
74
|
+
extensions?: readonly string[],
|
|
75
|
+
/**
|
|
76
|
+
* Every finished tool call's result text. The StallDetector's churn rule
|
|
77
|
+
* needs the size of what actually entered the child's context, which the
|
|
78
|
+
* CALL alone does not carry (task/stall-detector.ts).
|
|
79
|
+
*/
|
|
80
|
+
onToolResult?: (text: string, isError: boolean) => void): Promise<PhaseRunResult>;
|
|
71
81
|
interface PhaseDeps {
|
|
72
82
|
cwd: string;
|
|
73
83
|
taskId: string;
|
|
@@ -112,6 +122,23 @@ interface PhaseDeps {
|
|
|
112
122
|
/** Injectable delay for connection-error backoff; defaults to a real timer.
|
|
113
123
|
* Tests override it with a no-op so retries don't actually sleep. */
|
|
114
124
|
sleepFor?: (ms: number) => Promise<void>;
|
|
125
|
+
/**
|
|
126
|
+
* Run ONE named Child pi and return its assistant text — the seam every phase
|
|
127
|
+
* child goes through. Absent (production) → the real wrappers run, with the
|
|
128
|
+
* loop detector, the wall-clock budget and the Error-triage ladder. Present →
|
|
129
|
+
* the substitute answers directly and NONE of those guards run.
|
|
130
|
+
*
|
|
131
|
+
* The child's NAME is the first parameter because the name is what a caller
|
|
132
|
+
* branches on and what a test wants to assert. It used to be discarded before
|
|
133
|
+
* reaching the only injectable boundary (`spawn`), so a phase test had to
|
|
134
|
+
* reconstruct it by matching prompt PROSE against prompts.ts — which made
|
|
135
|
+
* prompt copy load-bearing test infrastructure in a codebase whose practice is
|
|
136
|
+
* rewording prompts and A/B-ing them.
|
|
137
|
+
*
|
|
138
|
+
* `spawn` stays: the ladder's OWN tests must drive a real process to exercise
|
|
139
|
+
* the rungs. This seam is for callers to whom the child is a premise.
|
|
140
|
+
*/
|
|
141
|
+
runChild?: (name: string, tools: string, prompt: string) => Promise<string>;
|
|
115
142
|
}
|
|
116
143
|
export type { PhaseDeps };
|
|
117
144
|
/**
|
|
@@ -123,16 +150,20 @@ export type { PhaseDeps };
|
|
|
123
150
|
* Empty completions and connection-class model errors share that same budget —
|
|
124
151
|
* see triageChildResult, which decides every one of those cases.
|
|
125
152
|
*
|
|
126
|
-
*
|
|
153
|
+
* THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
|
|
127
154
|
* /task-auto planning child goes through (clarify, decompose, coverage,
|
|
128
|
-
* contract-extract) and until mx5-n 2026-08-14 it had
|
|
155
|
+
* contract-extract) and until mx5-n 2026-08-14 it had none:
|
|
129
156
|
* • a LoopDetector, so an identical repeated tool call is killed and
|
|
130
157
|
* re-prompted instead of being allowed to fill the context window;
|
|
131
|
-
* •
|
|
132
|
-
* detector cannot see — the shape that actually cost us a
|
|
133
|
-
* decompose child that was never going to return.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
158
|
+
* • a StallDetector, the backstop for the varied-args thrash the loop
|
|
159
|
+
* detector's short window cannot see — the shape that actually cost us a
|
|
160
|
+
* 16-minute decompose child that was never going to return. It bounds
|
|
161
|
+
* consecutive no-new-ground calls and total context churn, NOT elapsed time;
|
|
162
|
+
* • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
|
|
163
|
+
* measured healthy range (610-927s for a reasoning-on decompose) overlaps
|
|
164
|
+
* any value that would catch the pathology. See its comment.
|
|
165
|
+
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
166
|
+
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
136
167
|
*/
|
|
137
168
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
|
|
138
169
|
export declare function formatLoopHint(hit: LoopHit): string;
|
|
@@ -10,6 +10,7 @@ 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
12
|
import { LoopDetector } from './loop-detector.js';
|
|
13
|
+
import { StallDetector, formatStallHint } from './stall-detector.js';
|
|
13
14
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
14
15
|
import { readSection, setTaskSection } from './task-io.js';
|
|
15
16
|
import { streamStallCause } from '../shared/stream-watchdog.js';
|
|
@@ -23,26 +24,30 @@ export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 rest
|
|
|
23
24
|
// MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
|
|
24
25
|
// ─── Phase-child wall-clock cap ──────────────────────────────────────────────
|
|
25
26
|
/**
|
|
26
|
-
*
|
|
27
|
+
* Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
|
|
27
28
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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.
|
|
29
|
+
* It used to default to 600_000, sized against measured HEALTHY planning
|
|
30
|
+
* children on one local 27B backend (decompose 89s, whole plan phase 321s) on
|
|
31
|
+
* the reasoning that ten minutes was a 3-6x margin over honest work.
|
|
35
32
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* a
|
|
33
|
+
* That premise was measured and is false. Replaying ONE captured auto-decompose
|
|
34
|
+
* request against the same backend with reasoning ON, n=10, everything else
|
|
35
|
+
* byte-identical (2026-08-17): all ten answered correctly with 26-42 titles, and
|
|
36
|
+
* every one took 610-927s. The cap would have killed 10 out of 10 GOOD runs and
|
|
37
|
+
* then failed the phase with PhaseTimeoutError. The number was not measuring the
|
|
38
|
+
* pathology, it was measuring one model's speed on one day.
|
|
39
|
+
*
|
|
40
|
+
* The runaway it was there to catch — a decompose child that ran 16m23s at
|
|
41
|
+
* 117,370 of a 120,064-token window, forward-paging past the loop detector — is
|
|
42
|
+
* now caught by StallDetector (stall-detector.ts), which bounds NON-PROGRESS and
|
|
43
|
+
* CONTEXT CHURN instead of elapsed seconds. Both of those are properties of the
|
|
44
|
+
* pathology, so neither has to be re-tuned for a slower model or a bigger repo.
|
|
45
|
+
*
|
|
46
|
+
* The value and the plumbing stay for a caller that genuinely wants a hard stop
|
|
47
|
+
* (tests inject a short one), but nothing sets it in production. Pass
|
|
48
|
+
* `timeoutMs` explicitly to arm it.
|
|
44
49
|
*/
|
|
45
|
-
export const PHASE_CHILD_TIMEOUT_MS =
|
|
50
|
+
export const PHASE_CHILD_TIMEOUT_MS = 0;
|
|
46
51
|
/**
|
|
47
52
|
* Restart hint after a phase child burns its whole wall-clock budget. It
|
|
48
53
|
* diagnoses over-exploration, which is what the cap actually catches — the same
|
|
@@ -161,7 +166,13 @@ export const USER_CANCELLED = '__user_cancelled__';
|
|
|
161
166
|
*/
|
|
162
167
|
export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawnFn,
|
|
163
168
|
/** Internal `-e` extension paths for in-run guards (see childArgs). */
|
|
164
|
-
extensions
|
|
169
|
+
extensions,
|
|
170
|
+
/**
|
|
171
|
+
* Every finished tool call's result text. The StallDetector's churn rule
|
|
172
|
+
* needs the size of what actually entered the child's context, which the
|
|
173
|
+
* CALL alone does not carry (task/stall-detector.ts).
|
|
174
|
+
*/
|
|
175
|
+
onToolResult) {
|
|
165
176
|
const invocation = getPiInvocation(childArgs(tools, extensions), prompt);
|
|
166
177
|
let loopHit;
|
|
167
178
|
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
@@ -173,6 +184,7 @@ extensions) {
|
|
|
173
184
|
streamInactivityMs: getConfig().streamInactivityMs,
|
|
174
185
|
onLine,
|
|
175
186
|
onContextUsage,
|
|
187
|
+
onToolResult: onToolResult ? r => onToolResult(r.text, r.isError) : undefined,
|
|
176
188
|
onToolCall: call => {
|
|
177
189
|
if (!onToolCall)
|
|
178
190
|
return null;
|
|
@@ -276,40 +288,53 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
|
|
|
276
288
|
* Empty completions and connection-class model errors share that same budget —
|
|
277
289
|
* see triageChildResult, which decides every one of those cases.
|
|
278
290
|
*
|
|
279
|
-
*
|
|
291
|
+
* THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
|
|
280
292
|
* /task-auto planning child goes through (clarify, decompose, coverage,
|
|
281
|
-
* contract-extract) and until mx5-n 2026-08-14 it had
|
|
293
|
+
* contract-extract) and until mx5-n 2026-08-14 it had none:
|
|
282
294
|
* • a LoopDetector, so an identical repeated tool call is killed and
|
|
283
295
|
* re-prompted instead of being allowed to fill the context window;
|
|
284
|
-
* •
|
|
285
|
-
* detector cannot see — the shape that actually cost us a
|
|
286
|
-
* decompose child that was never going to return.
|
|
287
|
-
*
|
|
288
|
-
*
|
|
296
|
+
* • a StallDetector, the backstop for the varied-args thrash the loop
|
|
297
|
+
* detector's short window cannot see — the shape that actually cost us a
|
|
298
|
+
* 16-minute decompose child that was never going to return. It bounds
|
|
299
|
+
* consecutive no-new-ground calls and total context churn, NOT elapsed time;
|
|
300
|
+
* • PHASE_CHILD_TIMEOUT_MS, a hard wall clock, OFF by default because the
|
|
301
|
+
* measured healthy range (610-927s for a reasoning-on decompose) overlaps
|
|
302
|
+
* any value that would catch the pathology. See its comment.
|
|
303
|
+
* All three are checked BEFORE the triage ladder: we killed the child, so its
|
|
304
|
+
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
289
305
|
*/
|
|
290
306
|
export async function runPhaseChild(deps, name, tools, prompt) {
|
|
307
|
+
if (deps.runChild)
|
|
308
|
+
return await deps.runChild(name, tools, prompt);
|
|
291
309
|
let hint = null;
|
|
292
310
|
const loopHistory = [];
|
|
293
311
|
const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
|
|
294
312
|
for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
|
|
295
313
|
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
314
|
+
const stall = new StallDetector();
|
|
296
315
|
const clock = phaseTimeout(deps.signal, budgetMs);
|
|
297
316
|
let r;
|
|
298
317
|
try {
|
|
299
|
-
r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput,
|
|
318
|
+
r = await runChild(deps.cwd, tools, prependHint(hint, prompt), clock.signal, deps.onChildOutput, snapshot => {
|
|
319
|
+
stall.noteContext(snapshot.contextWindow);
|
|
320
|
+
deps.onContextUsage?.(snapshot);
|
|
321
|
+
}, call => detector.record(call) ?? stall.record(call), deps.spawn, deps.childExtensions, (text, isError) => stall.noteResult(text, isError));
|
|
300
322
|
}
|
|
301
323
|
finally {
|
|
302
324
|
clock.cleanup();
|
|
303
325
|
}
|
|
304
|
-
// A user cancel must not be mistaken for
|
|
326
|
+
// A user cancel must not be mistaken for any of the guards.
|
|
305
327
|
if (deps.signal.aborted)
|
|
306
328
|
throw new Error(USER_CANCELLED);
|
|
307
329
|
if (r.loopHit) {
|
|
308
330
|
loopHistory.push(r.loopHit);
|
|
309
331
|
if (attempt === MAX_LEAK_RETRIES)
|
|
310
332
|
throw new LoopExhaustedError(name, loopHistory);
|
|
311
|
-
deps.logDebug?.(
|
|
312
|
-
|
|
333
|
+
deps.logDebug?.(r.loopHit.stall ?
|
|
334
|
+
`${name}: stalled (${r.loopHit.stall}) on ${r.loopHit.call.name} — `
|
|
335
|
+
+ `retry ${attempt + 1}/${MAX_LEAK_RETRIES}`
|
|
336
|
+
: `${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
|
|
337
|
+
hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
|
|
313
338
|
continue;
|
|
314
339
|
}
|
|
315
340
|
if (clock.timedOut()) {
|
|
@@ -366,6 +391,10 @@ async function appendLoopEvent(cwd, taskId, phase, hit, strike, outcome) {
|
|
|
366
391
|
await setTaskSection(cwd, taskId, 'loop events', next);
|
|
367
392
|
}
|
|
368
393
|
export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts = {}) {
|
|
394
|
+
// The substitute stands in for the whole guarded run, so it is handed the
|
|
395
|
+
// prompt the first strike would have used (no loop hint in flight yet).
|
|
396
|
+
if (deps.runChild)
|
|
397
|
+
return await deps.runChild(name, tools, buildPrompt(null));
|
|
369
398
|
const loopHistory = [];
|
|
370
399
|
// Carries the correction hint (loop OR leaked-tool-call) into the next strike.
|
|
371
400
|
let nextHint = null;
|
|
@@ -373,8 +402,12 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
|
|
|
373
402
|
if (deps.signal.aborted)
|
|
374
403
|
throw new Error(USER_CANCELLED);
|
|
375
404
|
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
405
|
+
const stall = new StallDetector();
|
|
376
406
|
const prompt = buildPrompt(nextHint);
|
|
377
|
-
const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput,
|
|
407
|
+
const r = await runChild(deps.cwd, tools, prompt, deps.signal, deps.onChildOutput, snapshot => {
|
|
408
|
+
stall.noteContext(snapshot.contextWindow);
|
|
409
|
+
deps.onContextUsage?.(snapshot);
|
|
410
|
+
}, call => detector.record(call) ?? stall.record(call), deps.spawn, undefined, (text, isError) => stall.noteResult(text, isError));
|
|
378
411
|
if (deps.signal.aborted)
|
|
379
412
|
throw new Error(USER_CANCELLED);
|
|
380
413
|
if (r.loopHit) {
|
|
@@ -388,7 +421,8 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
|
|
|
388
421
|
}
|
|
389
422
|
throw new LoopExhaustedError(name, loopHistory);
|
|
390
423
|
}
|
|
391
|
-
nextHint =
|
|
424
|
+
nextHint =
|
|
425
|
+
r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
|
|
392
426
|
continue;
|
|
393
427
|
}
|
|
394
428
|
// Everything past the loop kill is the shared ladder: exit code, model
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChildStatus — the live status of the child pi currently running under a
|
|
3
|
+
* status loader: its latest output line and its context usage.
|
|
4
|
+
*
|
|
5
|
+
* Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
|
|
6
|
+
* plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
|
|
7
|
+
* snapshot through `resolveContextUsage` with the parent window), a reset before
|
|
8
|
+
* every child, and a loader whose every tick read both — in `/task-auto`'s
|
|
9
|
+
* planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
|
|
10
|
+
* handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
|
|
11
|
+
* are one ritual and are now this class; the fourth stays where it is (see
|
|
12
|
+
* `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
|
|
13
|
+
* reference with `PhaseContext` and written by the phases themselves).
|
|
14
|
+
*
|
|
15
|
+
* `track` is the loader ritual: reset, raise the loader reading this status on
|
|
16
|
+
* every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
|
|
17
|
+
* shares one across every gate child, and the verify gate raises its own
|
|
18
|
+
* gate-wide loader over a child that renders none (`frame: null`), so both must
|
|
19
|
+
* see the same object.
|
|
20
|
+
*/
|
|
21
|
+
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
22
|
+
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
23
|
+
import { type PhaseDeps } from './child-runner.js';
|
|
24
|
+
import { type AutoLoaderState } from './widget.js';
|
|
25
|
+
export interface ChildStatusDeps {
|
|
26
|
+
/** The parent session's window — the last fallback for the context gauge. */
|
|
27
|
+
parentContextWindow: number;
|
|
28
|
+
/** Raise a loader. Defaults to the real `startAutoLoader`; a test injects a fake. */
|
|
29
|
+
startLoader?: (ctx: ExtensionCommandContext, getState: () => AutoLoaderState | null) => () => void;
|
|
30
|
+
}
|
|
31
|
+
export declare class ChildStatus {
|
|
32
|
+
private _lastLine;
|
|
33
|
+
private _contextUsage;
|
|
34
|
+
private readonly _parentContextWindow;
|
|
35
|
+
private readonly _startLoader;
|
|
36
|
+
constructor(deps: ChildStatusDeps);
|
|
37
|
+
/** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
|
|
38
|
+
onLine(line: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Fold a raw context_usage snapshot into the gauge: the child's own window,
|
|
41
|
+
* else the last known one, else the parent's (`resolveContextUsage`).
|
|
42
|
+
*/
|
|
43
|
+
onContextUsage(snapshot: ContextSnapshot): void;
|
|
44
|
+
/** Forget the previous child, so its trailer never sits under the next one's block. */
|
|
45
|
+
reset(): void;
|
|
46
|
+
/** The two live fields, as a loader frame reads them. */
|
|
47
|
+
snapshot(): {
|
|
48
|
+
lastLine?: string;
|
|
49
|
+
contextUsage?: ContextSnapshot;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Run `run` under the loader: reset, raise a loader whose every tick is
|
|
53
|
+
* `frame()` plus the live line and gauge, and stop it in a `finally` — a
|
|
54
|
+
* throwing child must not leave the widget up. `frame` wins on a clash, which
|
|
55
|
+
* is how the verify gate shows its deterministic-stage label until the child
|
|
56
|
+
* has a line of its own. `frame: null` renders NO loader (the caller already
|
|
57
|
+
* has one reading this status) but still resets, so the previous child's
|
|
58
|
+
* trailer is cleared either way.
|
|
59
|
+
*/
|
|
60
|
+
track<T>(ctx: ExtensionCommandContext, frame: (() => AutoLoaderState) | null, run: () => Promise<T>): Promise<T>;
|
|
61
|
+
}
|
|
62
|
+
/** What a planning child's loader shows: the head-line command, the title, the step. */
|
|
63
|
+
export interface PlanningChildLoader {
|
|
64
|
+
/** Head-line command. Omit for the loader's default (`/task-auto`). */
|
|
65
|
+
command?: string;
|
|
66
|
+
title: string;
|
|
67
|
+
/**
|
|
68
|
+
* The step for THIS child. Read on every tick, because /task-plan renames the
|
|
69
|
+
* step while a child runs (`setStatus`), and /task-auto numbers its steps.
|
|
70
|
+
*/
|
|
71
|
+
step: (name: string) => {
|
|
72
|
+
step: string;
|
|
73
|
+
stepNum: number;
|
|
74
|
+
stepTotal: number;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Run one planning child — a phase child (`runPhaseChild`, with its Error-triage
|
|
79
|
+
* ladder) whose only UI is the shared status loader. Both `/task-auto`'s
|
|
80
|
+
* planning `runChild` and `/task-plan`'s `child` are adapters over this: what
|
|
81
|
+
* they disagree on is the phase deps (task id, read-once extension, debug log),
|
|
82
|
+
* the tool set, and the loader's labelling — all parameters here. What
|
|
83
|
+
* `/task-plan` adds around it (the read-only tree diff) stays its own.
|
|
84
|
+
*/
|
|
85
|
+
export declare function runPlanningChild(opts: {
|
|
86
|
+
ctx: ExtensionCommandContext;
|
|
87
|
+
status: ChildStatus;
|
|
88
|
+
phaseDeps: PhaseDeps;
|
|
89
|
+
name: string;
|
|
90
|
+
tools: string;
|
|
91
|
+
prompt: string;
|
|
92
|
+
loader: PlanningChildLoader;
|
|
93
|
+
}): Promise<string>;
|
|
94
|
+
/** Wire a `ChildStatus` as a phase child's stream callbacks. */
|
|
95
|
+
export declare function statusCallbacks(status: ChildStatus): Pick<PhaseDeps, 'onChildOutput' | 'onContextUsage'>;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChildStatus — the live status of the child pi currently running under a
|
|
3
|
+
* status loader: its latest output line and its context usage.
|
|
4
|
+
*
|
|
5
|
+
* Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
|
|
6
|
+
* plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
|
|
7
|
+
* snapshot through `resolveContextUsage` with the parent window), a reset before
|
|
8
|
+
* every child, and a loader whose every tick read both — in `/task-auto`'s
|
|
9
|
+
* planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
|
|
10
|
+
* handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
|
|
11
|
+
* are one ritual and are now this class; the fourth stays where it is (see
|
|
12
|
+
* `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
|
|
13
|
+
* reference with `PhaseContext` and written by the phases themselves).
|
|
14
|
+
*
|
|
15
|
+
* `track` is the loader ritual: reset, raise the loader reading this status on
|
|
16
|
+
* every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
|
|
17
|
+
* shares one across every gate child, and the verify gate raises its own
|
|
18
|
+
* gate-wide loader over a child that renders none (`frame: null`), so both must
|
|
19
|
+
* see the same object.
|
|
20
|
+
*/
|
|
21
|
+
import { runPhaseChild } from './child-runner.js';
|
|
22
|
+
import { resolveContextUsage } from './context-usage.js';
|
|
23
|
+
import { startAutoLoader } from './widget.js';
|
|
24
|
+
export class ChildStatus {
|
|
25
|
+
_lastLine;
|
|
26
|
+
_contextUsage;
|
|
27
|
+
_parentContextWindow;
|
|
28
|
+
_startLoader;
|
|
29
|
+
constructor(deps) {
|
|
30
|
+
this._parentContextWindow = deps.parentContextWindow;
|
|
31
|
+
this._startLoader = deps.startLoader ?? startAutoLoader;
|
|
32
|
+
}
|
|
33
|
+
/** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
|
|
34
|
+
onLine(line) {
|
|
35
|
+
this._lastLine = line;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fold a raw context_usage snapshot into the gauge: the child's own window,
|
|
39
|
+
* else the last known one, else the parent's (`resolveContextUsage`).
|
|
40
|
+
*/
|
|
41
|
+
onContextUsage(snapshot) {
|
|
42
|
+
this._contextUsage = resolveContextUsage(snapshot, this._contextUsage, this._parentContextWindow);
|
|
43
|
+
}
|
|
44
|
+
/** Forget the previous child, so its trailer never sits under the next one's block. */
|
|
45
|
+
reset() {
|
|
46
|
+
this._lastLine = undefined;
|
|
47
|
+
this._contextUsage = undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The two live fields, as a loader frame reads them. */
|
|
50
|
+
snapshot() {
|
|
51
|
+
return { lastLine: this._lastLine, contextUsage: this._contextUsage };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run `run` under the loader: reset, raise a loader whose every tick is
|
|
55
|
+
* `frame()` plus the live line and gauge, and stop it in a `finally` — a
|
|
56
|
+
* throwing child must not leave the widget up. `frame` wins on a clash, which
|
|
57
|
+
* is how the verify gate shows its deterministic-stage label until the child
|
|
58
|
+
* has a line of its own. `frame: null` renders NO loader (the caller already
|
|
59
|
+
* has one reading this status) but still resets, so the previous child's
|
|
60
|
+
* trailer is cleared either way.
|
|
61
|
+
*/
|
|
62
|
+
async track(ctx, frame, run) {
|
|
63
|
+
this.reset();
|
|
64
|
+
const stop = frame === null ?
|
|
65
|
+
() => { }
|
|
66
|
+
: this._startLoader(ctx, () => ({ ...this.snapshot(), ...frame() }));
|
|
67
|
+
try {
|
|
68
|
+
return await run();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
stop();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run one planning child — a phase child (`runPhaseChild`, with its Error-triage
|
|
77
|
+
* ladder) whose only UI is the shared status loader. Both `/task-auto`'s
|
|
78
|
+
* planning `runChild` and `/task-plan`'s `child` are adapters over this: what
|
|
79
|
+
* they disagree on is the phase deps (task id, read-once extension, debug log),
|
|
80
|
+
* the tool set, and the loader's labelling — all parameters here. What
|
|
81
|
+
* `/task-plan` adds around it (the read-only tree diff) stays its own.
|
|
82
|
+
*/
|
|
83
|
+
export async function runPlanningChild(opts) {
|
|
84
|
+
const { ctx, status, phaseDeps, name, tools, prompt, loader } = opts;
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
return status.track(ctx, () => ({
|
|
87
|
+
...(loader.command === undefined ? {} : { command: loader.command }),
|
|
88
|
+
title: loader.title,
|
|
89
|
+
...loader.step(name),
|
|
90
|
+
startedAt
|
|
91
|
+
}), () => runPhaseChild(phaseDeps, name, tools, prompt));
|
|
92
|
+
}
|
|
93
|
+
/** Wire a `ChildStatus` as a phase child's stream callbacks. */
|
|
94
|
+
export function statusCallbacks(status) {
|
|
95
|
+
return {
|
|
96
|
+
onChildOutput: line => status.onLine(line),
|
|
97
|
+
onContextUsage: snapshot => status.onContextUsage(snapshot)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -102,3 +102,39 @@ export declare function outputTail(stdout: string, stderr: string, limit?: numbe
|
|
|
102
102
|
* an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
|
|
103
103
|
*/
|
|
104
104
|
export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
|
|
105
|
+
/**
|
|
106
|
+
* How a re-run of ONE recorded VERIFY command line ended.
|
|
107
|
+
* pass — it ran and exited 0. The ONLY outcome that may close a debt.
|
|
108
|
+
* fail — it ran and exited non-zero for a real reason. Debt stays open.
|
|
109
|
+
* gap — nothing was observed: the shell/runner never spawned, 127 inside the
|
|
110
|
+
* chain, a timeout, a missing browser, or absent external infrastructure.
|
|
111
|
+
* INCONCLUSIVE, so the debt stays open (surface, never re-hide).
|
|
112
|
+
*/
|
|
113
|
+
export type VerifyRerunOutcome = {
|
|
114
|
+
outcome: 'pass';
|
|
115
|
+
} | {
|
|
116
|
+
outcome: 'fail';
|
|
117
|
+
status: number;
|
|
118
|
+
tail: string;
|
|
119
|
+
} | {
|
|
120
|
+
outcome: 'gap';
|
|
121
|
+
detail: string;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
125
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
126
|
+
* command itself rather than by a judgement about it.
|
|
127
|
+
*
|
|
128
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
129
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
130
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
131
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
132
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
133
|
+
*
|
|
134
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
135
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
136
|
+
* debt exactly as open as it was.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
|
|
139
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
140
|
+
run?: CommandRunner): VerifyRerunOutcome;
|
package/dist/task/command-run.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* had none.
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from 'node:child_process';
|
|
31
|
-
import { isCommandNotFound } from './runner-resolve.js';
|
|
31
|
+
import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
32
32
|
/** The real runner. */
|
|
33
33
|
export const spawnCommand = spec => {
|
|
34
34
|
const r = spawnSync(spec.bin, spec.args, {
|
|
@@ -136,3 +136,50 @@ export function classifyCommandRun(run, gapPatterns = []) {
|
|
|
136
136
|
tail: outputTail(run.stdout, run.stderr)
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
/** The command word of a shell line, past any leading `VAR=value` assignments. */
|
|
140
|
+
function leadingBin(line) {
|
|
141
|
+
for (const tok of line.trim().split(/\s+/)) {
|
|
142
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
|
|
143
|
+
continue;
|
|
144
|
+
return tok;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
150
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
151
|
+
* command itself rather than by a judgement about it.
|
|
152
|
+
*
|
|
153
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
154
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
155
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
156
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
157
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
158
|
+
*
|
|
159
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
160
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
161
|
+
* debt exactly as open as it was.
|
|
162
|
+
*/
|
|
163
|
+
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
164
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
165
|
+
run = spawnCommand) {
|
|
166
|
+
const bin = leadingBin(line);
|
|
167
|
+
const runner = bin === null ? null : resolveRunner(bin);
|
|
168
|
+
// A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
|
|
169
|
+
// redirects are all ordinary there — so the runner spawns `sh -c`.
|
|
170
|
+
const verdict = classifyCommandRun(run({
|
|
171
|
+
cwd,
|
|
172
|
+
bin: 'sh',
|
|
173
|
+
args: ['-c', line],
|
|
174
|
+
timeoutMs,
|
|
175
|
+
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
176
|
+
}),
|
|
177
|
+
// Infrastructure counts as a gap on EVERY debt re-run, not only on
|
|
178
|
+
// request: an unreachable database cannot tell us whether the code is
|
|
179
|
+
// fixed, and the asymmetry below means an inconclusive re-run simply
|
|
180
|
+
// leaves the debt as open as it was.
|
|
181
|
+
extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
|
|
182
|
+
if (verdict.outcome === 'gap')
|
|
183
|
+
return { outcome: 'gap', detail: verdict.detail };
|
|
184
|
+
return verdict;
|
|
185
|
+
}
|
|
@@ -34,7 +34,7 @@ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WA
|
|
|
34
34
|
/**
|
|
35
35
|
* One-shot marker: the most recent turn abort was issued BY THE WATCHDOG, not by
|
|
36
36
|
* a human ESC. Both end the assistant turn with stopReason 'aborted' — the only
|
|
37
|
-
* signal steerUntilDone's
|
|
37
|
+
* signal steerUntilDone's classifyTurnEnd() can read — so without this flag the
|
|
38
38
|
* steer loop can win the race against the watchdog's queued follow-up turn and
|
|
39
39
|
* show a steering prompt to an empty room (wedging an unattended run).
|
|
40
40
|
*
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context-usage resolution —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Context-usage resolution — the one piece of math every child-status mirror
|
|
3
|
+
* shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
|
|
4
|
+
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
|
+
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
5
6
|
*/
|
|
6
7
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
7
8
|
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context-usage resolution —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Context-usage resolution — the one piece of math every child-status mirror
|
|
3
|
+
* shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
|
|
4
|
+
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
|
+
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
5
6
|
*/
|
|
6
7
|
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
7
8
|
export function getParentContextWindow(ctx) {
|