@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.
Files changed (59) hide show
  1. package/README.md +8 -5
  2. package/dist/config/config.d.ts +0 -1
  3. package/dist/config/config.js +0 -1
  4. package/dist/config/register.js +0 -2
  5. package/dist/index.js +0 -2
  6. package/dist/shared/child-process.d.ts +8 -0
  7. package/dist/shared/command-watchdog.d.ts +1 -1
  8. package/dist/shared/command-watchdog.js +1 -1
  9. package/dist/task/accept-debt.d.ts +47 -0
  10. package/dist/task/accept-debt.js +127 -28
  11. package/dist/task/auto-orchestrator.js +91 -114
  12. package/dist/task/child-runner.d.ts +39 -25
  13. package/dist/task/child-runner.js +59 -31
  14. package/dist/task/child-status.d.ts +95 -0
  15. package/dist/task/child-status.js +99 -0
  16. package/dist/task/command-run.d.ts +36 -0
  17. package/dist/task/command-run.js +48 -1
  18. package/dist/task/command-watchdog.js +1 -1
  19. package/dist/task/context-usage.d.ts +4 -3
  20. package/dist/task/context-usage.js +4 -3
  21. package/dist/task/contracts.js +18 -35
  22. package/dist/task/deep-render-check.d.ts +47 -0
  23. package/dist/task/deep-render-check.js +110 -65
  24. package/dist/task/env-notes.d.ts +3 -3
  25. package/dist/task/env-notes.js +24 -35
  26. package/dist/task/final-gate-fix.d.ts +1 -1
  27. package/dist/task/final-gate-fix.js +1 -1
  28. package/dist/task/final-gate.d.ts +5 -151
  29. package/dist/task/final-gate.js +81 -379
  30. package/dist/task/gate-child.d.ts +8 -10
  31. package/dist/task/gate-child.js +15 -19
  32. package/dist/task/gate-deps.d.ts +29 -0
  33. package/dist/task/gate-deps.js +192 -206
  34. package/dist/task/gate-tally.d.ts +189 -0
  35. package/dist/task/gate-tally.js +249 -0
  36. package/dist/task/implementation-turn.d.ts +201 -0
  37. package/dist/task/implementation-turn.js +263 -0
  38. package/dist/task/launch-contract.js +27 -43
  39. package/dist/task/ledger.d.ts +38 -0
  40. package/dist/task/ledger.js +83 -0
  41. package/dist/task/loop-detector.d.ts +14 -8
  42. package/dist/task/loop-detector.js +36 -12
  43. package/dist/task/orchestrator.d.ts +61 -126
  44. package/dist/task/orchestrator.js +67 -294
  45. package/dist/task/plan-orchestrator.js +34 -33
  46. package/dist/task/requirements.d.ts +1 -1
  47. package/dist/task/requirements.js +50 -66
  48. package/dist/task/root-cause-repair.js +20 -32
  49. package/dist/task/run-bracket.d.ts +75 -0
  50. package/dist/task/run-bracket.js +41 -0
  51. package/dist/task/stall-detector.d.ts +110 -0
  52. package/dist/task/stall-detector.js +159 -0
  53. package/dist/task/verify-work.d.ts +53 -67
  54. package/dist/task/verify-work.js +15 -11
  55. package/dist/workers/single-read-extension.d.ts +1 -1
  56. package/dist/workers/single-read-extension.js +5 -4
  57. package/dist/workers/single-read-guard.d.ts +32 -10
  58. package/dist/workers/single-read-guard.js +67 -16
  59. 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 { runPhaseChild, prependHint, USER_CANCELLED } from './child-runner.js';
23
+ import { prependHint, USER_CANCELLED } from './child-runner.js';
24
24
  import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
25
- import { armCancelListener, disarmCancelListener } from './cancel-input.js';
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 { pushNotify } from '../remote/push.js';
31
- import { startAutoLoader } from './widget.js';
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
- // Captured by the planning loader's getState so the widget mirrors the child's
1130
- // latest output line and context usage, exactly like the single-task phase
1131
- // widget. (The gate children manage their own loaders inside buildGateDeps.)
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
- onChildOutput: (line) => {
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
- runChild: async (name, tools, prompt) => {
1158
- // Planning children are slow LLM calls with no UI of their own; show
1159
- // the same status block as /task so this never goes silent until the
1160
- // drill dialog.
1161
- lastLine = undefined;
1162
- contextUsage = undefined;
1163
- const startedAt = Date.now();
1164
- const { step, stepNum } = AUTO_PLAN_STEPS[name] ?? { step: name, stepNum: 1 };
1165
- const stopLoader = startAutoLoader(ctx, () => ({
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
- stepNum,
1169
- stepTotal: AUTO_PLAN_STEP_TOTAL,
1170
- startedAt,
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
- finally {
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.ui.notify(msg, level);
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
- beginRun(); // the whole loop owns the session, not just the task inside it
1465
- // Take delivery of a typed /task-auto-cancel for the WHOLE run, planning
1466
- // included planning is children too, so the host is not streaming and the
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
- // Stamp a fresh per-run research-cache id (F10) BEFORE planning so enrichment and
1471
- // every task's research phase share one run's cache; disabled clears any token a
1472
- // prior run left, so nothing is cached.
1473
- configureResearchRun(getConfig().researchCache);
1474
- const abort = new AbortController();
1475
- const deps = defaultDeps(ctx, cwd, abort.signal, deriveTitle(raw));
1476
- let id;
1477
- try {
1478
- id = await planAuto(ctx, cwd, raw, deps);
1479
- }
1480
- catch (err) {
1481
- const msg = err instanceof Error ? err.message : String(err);
1482
- if (msg === USER_CANCELLED) {
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
- announceDone(ctx, `/task-auto planning failed: ${msg}`, 'error');
1487
- return;
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
- beginRun(); // the whole loop owns the session, not just the task inside it
1533
- armTerminalCancel(ctx);
1512
+ // The whole loop owns the session, not just the task inside it.
1534
1513
  try {
1535
- // Reuse the interrupted run's research-cache id, dropping only the entries whose
1536
- // own package moved version (F10). mx5 run 13 resumed three times and each
1537
- // resume's fresh id discarded a working 201-entry cache; run 14 then showed a
1538
- // whole-file freshness gate can never hold on a greenfield run that installs
1539
- // packages as it goes, so invalidation is per entry. See resumeResearchRun.
1540
- const research = await resumeResearchRun(cwd, getConfig().researchCache);
1541
- if (research.reused) {
1542
- logPlanDebug(cwd, `research cache: resume reused ${research.entries} entr(ies), `
1543
- + `dropped ${research.dropped} stale`);
1544
- }
1545
- const abort = new AbortController();
1546
- // Resume only runs the loop (runTask); no planning children, so the loader
1547
- // title is unused here pass the id for clarity if that ever changes.
1548
- await runAutoLoop(ctx, cwd, id, defaultDeps(ctx, cwd, abort.signal, id));
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. `armCancelListener` watches raw stdin, so it works during the spec
1575
- * phases and the gates — the windows where pi would otherwise queue the line
1576
- * until after the run (see cancel-input.ts). The remote path is unaffected:
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 armTerminalCancel(ctx) {
1580
- armCancelListener(ctx, live => {
1581
- requestAutoCancel();
1582
- // `live` is the ctx the listener is currently installed on — the captured
1583
- // one is stale the moment a task replaces the session.
1584
- try {
1585
- live.ui.notify(CANCEL_ACK, 'warning');
1586
- }
1587
- catch {
1588
- /* the acknowledgement must never break the cancel itself */
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
- * Hard wall-clock bound on ONE spawn of a phase child.
14
+ * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
15
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.
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
- * 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.
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 = 600000;
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[]): Promise<PhaseRunResult>;
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
- * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
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 neither:
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
- * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
149
- * detector cannot see — the shape that actually cost us a 16-minute
150
- * decompose child that was never going to return.
151
- * Both are checked BEFORE the triage ladder: we killed the child, so its exit
152
- * status describes our SIGTERM and says nothing about its verdict.
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
- * Hard wall-clock bound on ONE spawn of a phase child.
27
+ * Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
27
28
  *
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.
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
- * 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.
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 = 600_000;
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
- * TWO RUNAWAY GUARDS ride the same budget, because this is the runner every
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 neither:
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
- * • PHASE_CHILD_TIMEOUT_MS, the backstop for the varied-args thrash the
285
- * detector cannot see — the shape that actually cost us a 16-minute
286
- * decompose child that was never going to return.
287
- * Both are checked BEFORE the triage ladder: we killed the child, so its exit
288
- * status describes our SIGTERM and says nothing about its verdict.
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, deps.onContextUsage, call => detector.record(call), deps.spawn, deps.childExtensions);
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 either guard.
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?.(`${name}: looped on ${r.loopHit.call.name} — retry ${attempt + 1}/${MAX_LEAK_RETRIES}`);
314
- hint = formatLoopHint(r.loopHit);
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, deps.onContextUsage, call => detector.record(call), deps.spawn);
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 = formatLoopHint(r.loopHit);
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'>;