@mjasnikovs/pi-task 0.38.11 → 0.38.13
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 +8 -5
- package/dist/config/config.d.ts +0 -1
- package/dist/config/config.js +0 -1
- package/dist/config/register.js +0 -2
- package/dist/index.js +0 -2
- 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 +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -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 +5 -151
- package/dist/task/final-gate.js +81 -379
- 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/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 +67 -294
- 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/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- 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
|
@@ -20,16 +20,13 @@ import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairT
|
|
|
20
20
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
21
21
|
import { readTextFile } from '../shared/fs-text.js';
|
|
22
22
|
import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
23
|
-
import {
|
|
23
|
+
import { prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
24
|
import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
|
|
25
|
-
import {
|
|
26
|
-
import { beginRun, endRun } from './mid-run-input.js';
|
|
27
|
-
import { reportDroppedInput } from './dropped-input.js';
|
|
25
|
+
import { withRun, announceTerminal } from './run-bracket.js';
|
|
28
26
|
import { refineExistingFilesBlock, SINGLE_READ_EXTENSION_PATH } from './phases.js';
|
|
29
27
|
import { SessionUI, registerBridgeCommand, publishLifecycleNotice } from '../remote/bridge.js';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { getParentContextWindow, resolveContextUsage } from './context-usage.js';
|
|
28
|
+
import { getParentContextWindow } from './context-usage.js';
|
|
29
|
+
import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
|
|
33
30
|
import { buildGateDeps, collectTreeChanges } from './gate-deps.js';
|
|
34
31
|
import { runGatesForTask } from './task-gates.js';
|
|
35
32
|
import { runFinalGateStage } from './run-final-gate.js';
|
|
@@ -1126,12 +1123,11 @@ const AUTO_PLAN_STEPS = {
|
|
|
1126
1123
|
};
|
|
1127
1124
|
const AUTO_PLAN_STEP_TOTAL = 2;
|
|
1128
1125
|
function defaultDeps(ctx, cwd, signal, title) {
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
let lastLine;
|
|
1133
|
-
let contextUsage;
|
|
1126
|
+
// The planning loader mirrors the child's latest output line and context
|
|
1127
|
+
// usage, exactly like the single-task phase widget. (The gate children have
|
|
1128
|
+
// their own ChildStatus inside buildGateDeps.)
|
|
1134
1129
|
const parentContextWindow = getParentContextWindow(ctx);
|
|
1130
|
+
const status = new ChildStatus({ parentContextWindow });
|
|
1135
1131
|
const phaseDeps = {
|
|
1136
1132
|
cwd,
|
|
1137
1133
|
taskId: '',
|
|
@@ -1143,41 +1139,31 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
1143
1139
|
// it has already opened can only be thrash — which makes the read-once
|
|
1144
1140
|
// block safe here in a way it is not for a phase that must explore.
|
|
1145
1141
|
childExtensions: [SINGLE_READ_EXTENSION_PATH],
|
|
1146
|
-
|
|
1147
|
-
lastLine = line;
|
|
1148
|
-
},
|
|
1149
|
-
onContextUsage: snapshot => {
|
|
1150
|
-
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
1151
|
-
}
|
|
1142
|
+
...statusCallbacks(status)
|
|
1152
1143
|
};
|
|
1153
1144
|
return {
|
|
1154
1145
|
// Planning-only seam. The shared gate surface (runTask/commit/verify/
|
|
1155
1146
|
// enforce/recommend/revert) comes from buildGateDeps below — identical to
|
|
1156
1147
|
// what /task builds, so both commands gate the same way.
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1148
|
+
//
|
|
1149
|
+
// Planning children are slow LLM calls with no UI of their own; the
|
|
1150
|
+
// shared loader shows the same status block as /task so this never goes
|
|
1151
|
+
// silent until the drill dialog.
|
|
1152
|
+
runChild: (name, tools, prompt) => runPlanningChild({
|
|
1153
|
+
ctx,
|
|
1154
|
+
status,
|
|
1155
|
+
phaseDeps,
|
|
1156
|
+
name,
|
|
1157
|
+
tools,
|
|
1158
|
+
prompt,
|
|
1159
|
+
loader: {
|
|
1166
1160
|
title,
|
|
1167
|
-
step
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
lastLine,
|
|
1172
|
-
contextUsage
|
|
1173
|
-
}));
|
|
1174
|
-
try {
|
|
1175
|
-
return await runPhaseChild(phaseDeps, name, tools, prompt);
|
|
1161
|
+
step: n => ({
|
|
1162
|
+
...(AUTO_PLAN_STEPS[n] ?? { step: n, stepNum: 1 }),
|
|
1163
|
+
stepTotal: AUTO_PLAN_STEP_TOTAL
|
|
1164
|
+
})
|
|
1176
1165
|
}
|
|
1177
|
-
|
|
1178
|
-
stopLoader();
|
|
1179
|
-
}
|
|
1180
|
-
},
|
|
1166
|
+
}),
|
|
1181
1167
|
...buildGateDeps({ signal, parentContextWindow, runTask: gateRunTask }),
|
|
1182
1168
|
// Loop-level repo integrity + run-end gate glue (see AutoDeps docs).
|
|
1183
1169
|
unmergedPaths: cwd2 => gitUnmergedPaths(cwd2, signal),
|
|
@@ -1213,12 +1199,7 @@ export function requestAutoCancel() {
|
|
|
1213
1199
|
* runSingleTask without notifyFinish, so they stay silent).
|
|
1214
1200
|
*/
|
|
1215
1201
|
function announceDone(ctx, msg, level) {
|
|
1216
|
-
ctx
|
|
1217
|
-
// ctx.ui.notify is terminal-only and pushNotify is a backgrounded-device web
|
|
1218
|
-
// push — neither shows up in a remote viewer that's watching live. Mirror it
|
|
1219
|
-
// into the session view too (errors become a persistent red bubble).
|
|
1220
|
-
publishLifecycleNotice(msg, level);
|
|
1221
|
-
void pushNotify('Task finished', msg, 'pi-end').catch(() => { });
|
|
1202
|
+
announceTerminal(ctx, msg, level);
|
|
1222
1203
|
}
|
|
1223
1204
|
export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
1224
1205
|
resetCancel();
|
|
@@ -1461,46 +1442,45 @@ async function handleTaskAuto(args, ctx) {
|
|
|
1461
1442
|
return;
|
|
1462
1443
|
}
|
|
1463
1444
|
autoRunning = true;
|
|
1464
|
-
|
|
1465
|
-
//
|
|
1466
|
-
// included
|
|
1467
|
-
// ordinary command path cannot reach us.
|
|
1468
|
-
armTerminalCancel(ctx);
|
|
1445
|
+
// The whole loop owns the session, not just the task inside it — and the
|
|
1446
|
+
// bracket takes delivery of a typed /task-auto-cancel for the WHOLE run,
|
|
1447
|
+
// planning included: planning is children too, so the host is not streaming
|
|
1448
|
+
// and the ordinary command path cannot reach us.
|
|
1469
1449
|
try {
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1450
|
+
await withRun(ctx, { onCancel: terminalCancel }, async () => {
|
|
1451
|
+
// Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
|
|
1452
|
+
// every task's research phase share one run's cache; disabled ⇒ clears any token a
|
|
1453
|
+
// prior run left, so nothing is cached.
|
|
1454
|
+
configureResearchRun(getConfig().researchCache);
|
|
1455
|
+
const abort = new AbortController();
|
|
1456
|
+
const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
|
|
1457
|
+
let id;
|
|
1458
|
+
try {
|
|
1459
|
+
id = await planAuto(ctx, cwd, raw, deps);
|
|
1460
|
+
}
|
|
1461
|
+
catch (err) {
|
|
1462
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1463
|
+
if (msg === USER_CANCELLED) {
|
|
1464
|
+
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (!id)
|
|
1471
|
+
return;
|
|
1472
|
+
// Check for a cancel that was requested during the planning phase before the
|
|
1473
|
+
// loop resets the flag.
|
|
1474
|
+
if (isCancelRequested()) {
|
|
1475
|
+
resetCancel();
|
|
1483
1476
|
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
1484
1477
|
return;
|
|
1485
1478
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
}
|
|
1489
|
-
if (!id)
|
|
1490
|
-
return;
|
|
1491
|
-
// Check for a cancel that was requested during the planning phase before the
|
|
1492
|
-
// loop resets the flag.
|
|
1493
|
-
if (isCancelRequested()) {
|
|
1494
|
-
resetCancel();
|
|
1495
|
-
announceDone(ctx, '/task-auto cancelled.', 'warning');
|
|
1496
|
-
return;
|
|
1497
|
-
}
|
|
1498
|
-
await runAutoLoop(ctx, cwd, id, deps);
|
|
1479
|
+
await runAutoLoop(ctx, cwd, id, deps);
|
|
1480
|
+
});
|
|
1499
1481
|
}
|
|
1500
1482
|
finally {
|
|
1501
1483
|
autoRunning = false;
|
|
1502
|
-
reportDroppedInput(endRun(), ctx);
|
|
1503
|
-
disarmCancelListener();
|
|
1504
1484
|
}
|
|
1505
1485
|
}
|
|
1506
1486
|
async function handleTaskAutoResume(args, ctx) {
|
|
@@ -1529,28 +1509,27 @@ async function handleTaskAutoResume(args, ctx) {
|
|
|
1529
1509
|
const id = candidate.id;
|
|
1530
1510
|
await updateTaskFrontMatter(cwd, id, { state: 'in_progress' });
|
|
1531
1511
|
autoRunning = true;
|
|
1532
|
-
|
|
1533
|
-
armTerminalCancel(ctx);
|
|
1512
|
+
// The whole loop owns the session, not just the task inside it.
|
|
1534
1513
|
try {
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1514
|
+
await withRun(ctx, { onCancel: terminalCancel }, async () => {
|
|
1515
|
+
// Reuse the interrupted run's research-cache id, dropping only the entries whose
|
|
1516
|
+
// own package moved version (F10). mx5 run 13 resumed three times and each
|
|
1517
|
+
// resume's fresh id discarded a working 201-entry cache; run 14 then showed a
|
|
1518
|
+
// whole-file freshness gate can never hold on a greenfield run that installs
|
|
1519
|
+
// packages as it goes, so invalidation is per entry. See resumeResearchRun.
|
|
1520
|
+
const research = await resumeResearchRun(cwd, getConfig().researchCache);
|
|
1521
|
+
if (research.reused) {
|
|
1522
|
+
logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
|
|
1523
|
+
+ `dropped ${research.dropped} stale`);
|
|
1524
|
+
}
|
|
1525
|
+
const abort = new AbortController();
|
|
1526
|
+
// Resume only runs the loop (runTask); no planning children, so the loader
|
|
1527
|
+
// title is unused here — pass the id for clarity if that ever changes.
|
|
1528
|
+
await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
|
|
1529
|
+
});
|
|
1549
1530
|
}
|
|
1550
1531
|
finally {
|
|
1551
1532
|
autoRunning = false;
|
|
1552
|
-
reportDroppedInput(endRun(), ctx);
|
|
1553
|
-
disarmCancelListener();
|
|
1554
1533
|
}
|
|
1555
1534
|
}
|
|
1556
1535
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
@@ -1571,24 +1550,22 @@ async function handleTaskAutoCancel(_args, ctx) {
|
|
|
1571
1550
|
const CANCEL_ACK = 'Stopping /task-auto at the next safe checkpoint…';
|
|
1572
1551
|
/**
|
|
1573
1552
|
* Deliver a /task-auto-cancel typed in the terminal while a run owns the main
|
|
1574
|
-
* loop
|
|
1575
|
-
* phases and the gates — the windows where pi would
|
|
1576
|
-
* until after the run (see cancel-input.ts). The
|
|
1577
|
-
* dispatchRemoteLine invokes the handler directly.
|
|
1553
|
+
* loop — the run bracket's `onCancel`. The armed listener watches raw stdin, so
|
|
1554
|
+
* it works during the spec phases and the gates — the windows where pi would
|
|
1555
|
+
* otherwise queue the line until after the run (see cancel-input.ts). The
|
|
1556
|
+
* remote path is unaffected: dispatchRemoteLine invokes the handler directly.
|
|
1578
1557
|
*/
|
|
1579
|
-
function
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
publishLifecycleNotice(CANCEL_ACK, 'warning');
|
|
1591
|
-
});
|
|
1558
|
+
function terminalCancel(live) {
|
|
1559
|
+
requestAutoCancel();
|
|
1560
|
+
// `live` is the ctx the listener is currently installed on — the captured
|
|
1561
|
+
// one is stale the moment a task replaces the session.
|
|
1562
|
+
try {
|
|
1563
|
+
live.ui.notify(CANCEL_ACK, 'warning');
|
|
1564
|
+
}
|
|
1565
|
+
catch {
|
|
1566
|
+
/* the acknowledgement must never break the cancel itself */
|
|
1567
|
+
}
|
|
1568
|
+
publishLifecycleNotice(CANCEL_ACK, 'warning');
|
|
1592
1569
|
}
|
|
1593
1570
|
// ─── Registration ────────────────────────────────────────────────────────────
|
|
1594
1571
|
export function registerTaskAuto(pi) {
|
|
@@ -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;
|
|
@@ -140,16 +150,20 @@ export type { PhaseDeps };
|
|
|
140
150
|
* Empty completions and connection-class model errors share that same budget —
|
|
141
151
|
* see triageChildResult, which decides every one of those cases.
|
|
142
152
|
*
|
|
143
|
-
*
|
|
153
|
+
* THREE RUNAWAY GUARDS ride the same budget, because this is the runner every
|
|
144
154
|
* /task-auto planning child goes through (clarify, decompose, coverage,
|
|
145
|
-
* contract-extract) and until mx5-n 2026-08-14 it had
|
|
155
|
+
* contract-extract) and until mx5-n 2026-08-14 it had none:
|
|
146
156
|
* • a LoopDetector, so an identical repeated tool call is killed and
|
|
147
157
|
* re-prompted instead of being allowed to fill the context window;
|
|
148
|
-
* •
|
|
149
|
-
* detector cannot see — the shape that actually cost us a
|
|
150
|
-
* decompose child that was never going to return.
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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.
|
|
153
167
|
*/
|
|
154
168
|
export declare function runPhaseChild(deps: PhaseDeps, name: string, tools: string, prompt: string): Promise<string>;
|
|
155
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,16 +288,20 @@ 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) {
|
|
291
307
|
if (deps.runChild)
|
|
@@ -295,23 +311,30 @@ export async function runPhaseChild(deps, name, tools, prompt) {
|
|
|
295
311
|
const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
|
|
296
312
|
for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
|
|
297
313
|
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
314
|
+
const stall = new StallDetector();
|
|
298
315
|
const clock = phaseTimeout(deps.signal, budgetMs);
|
|
299
316
|
let r;
|
|
300
317
|
try {
|
|
301
|
-
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));
|
|
302
322
|
}
|
|
303
323
|
finally {
|
|
304
324
|
clock.cleanup();
|
|
305
325
|
}
|
|
306
|
-
// A user cancel must not be mistaken for
|
|
326
|
+
// A user cancel must not be mistaken for any of the guards.
|
|
307
327
|
if (deps.signal.aborted)
|
|
308
328
|
throw new Error(USER_CANCELLED);
|
|
309
329
|
if (r.loopHit) {
|
|
310
330
|
loopHistory.push(r.loopHit);
|
|
311
331
|
if (attempt === MAX_LEAK_RETRIES)
|
|
312
332
|
throw new LoopExhaustedError(name, loopHistory);
|
|
313
|
-
deps.logDebug?.(
|
|
314
|
-
|
|
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);
|
|
315
338
|
continue;
|
|
316
339
|
}
|
|
317
340
|
if (clock.timedOut()) {
|
|
@@ -379,8 +402,12 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
|
|
|
379
402
|
if (deps.signal.aborted)
|
|
380
403
|
throw new Error(USER_CANCELLED);
|
|
381
404
|
const detector = new LoopDetector(LOOP_WINDOW, LOOP_THRESHOLD);
|
|
405
|
+
const stall = new StallDetector();
|
|
382
406
|
const prompt = buildPrompt(nextHint);
|
|
383
|
-
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));
|
|
384
411
|
if (deps.signal.aborted)
|
|
385
412
|
throw new Error(USER_CANCELLED);
|
|
386
413
|
if (r.loopHit) {
|
|
@@ -394,7 +421,8 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt, opts
|
|
|
394
421
|
}
|
|
395
422
|
throw new LoopExhaustedError(name, loopHistory);
|
|
396
423
|
}
|
|
397
|
-
nextHint =
|
|
424
|
+
nextHint =
|
|
425
|
+
r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
|
|
398
426
|
continue;
|
|
399
427
|
}
|
|
400
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'>;
|