@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
@@ -30,10 +30,8 @@
30
30
  * is exactly what gets injected, and compose's VERIFY rules fold it into every
31
31
  * applicable task's runnable verification.
32
32
  */
33
- import * as fsp from 'node:fs/promises';
34
- import * as path from 'node:path';
35
- import { tasksDir } from './task-io.js';
36
33
  import { normalise } from './contracts.js';
34
+ import { makeLedger } from './ledger.js';
37
35
  const REQUIREMENTS_FILE = 'requirements.md';
38
36
  /** Cap kept entries so the injected block stays bounded on a large design. */
39
37
  const MAX_REQUIREMENTS = 40;
@@ -41,8 +39,22 @@ const MAX_REQUIREMENTS = 40;
41
39
  const MAX_REQUIREMENT_LENGTH = 300;
42
40
  /** Too short to state an obligation (and to ground unambiguously). */
43
41
  const MIN_QUOTE_LENGTH = 6;
42
+ function carriedLineKey(line) {
43
+ const q = /"([^"]+)"/.exec(line);
44
+ return normalise(q ? q[1] : line);
45
+ }
46
+ const carried = makeLedger({
47
+ file: REQUIREMENTS_FILE,
48
+ max: MAX_REQUIREMENTS,
49
+ key: c => c.key,
50
+ serialize: c => c.line,
51
+ parse: raw => raw
52
+ .split('\n')
53
+ .filter(l => l.trim().length > 0)
54
+ .map(line => ({ line, key: carriedLineKey(line) }))
55
+ });
44
56
  export function requirementsFile(cwd) {
45
- return path.join(tasksDir(cwd), REQUIREMENTS_FILE);
57
+ return carried.path(cwd);
46
58
  }
47
59
  /** Parse `REQUIREMENT: "<quote>" [anchor: …]` lines (mirrors parseContractLines). */
48
60
  export function parseRequirementLines(text) {
@@ -485,12 +497,7 @@ export function accountCoverage(requirements, mappings) {
485
497
  // ─── The carried-requirements artifact + injection block ────────────────────
486
498
  /** The stored carried-requirements text ('' when none recorded). */
487
499
  export async function readRequirements(cwd) {
488
- try {
489
- return (await fsp.readFile(requirementsFile(cwd), 'utf8')).trim();
490
- }
491
- catch {
492
- return '';
493
- }
500
+ return carried.readRaw(cwd);
494
501
  }
495
502
  function formatEntry(e, marker) {
496
503
  const anchor = e.anchor ? ` [anchor: ${e.anchor}]` : '';
@@ -516,45 +523,23 @@ function formatEntry(e, marker) {
516
523
  * judge areas they are host-authored strings, not source quotes.
517
524
  */
518
525
  export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = [], danglingArtifacts = []) {
519
- if (crossCutting.length === 0
520
- && unresolved.length === 0
521
- && judgeFlagged.length === 0
522
- && danglingArtifacts.length === 0) {
523
- return;
524
- }
525
- try {
526
- const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
527
- const seen = new Set(existing.map(l => {
528
- const q = /"([^"]+)"/.exec(l);
529
- return normalise(q ? q[1] : l);
530
- }));
531
- const merged = [...existing];
532
- for (const [entries, marker] of [
533
- [crossCutting, undefined],
534
- [unresolved, 'no task owns this — surfaced at plan time'],
535
- [
536
- judgeFlagged.map(q => ({ quote: q, anchor: '' })),
537
- 'judge-flagged uncovered area, no task owns this — surfaced at plan time'
538
- ],
539
- [
540
- danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
541
- 'dangling runtime artifact, nothing produces it — surfaced at plan time'
542
- ]
543
- ]) {
544
- for (const e of entries) {
545
- const key = normalise(e.quote);
546
- if (seen.has(key))
547
- continue;
548
- seen.add(key);
549
- merged.push(formatEntry(e, marker));
550
- }
551
- }
552
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
553
- await fsp.writeFile(requirementsFile(cwd), merged.slice(-MAX_REQUIREMENTS).join('\n') + '\n', 'utf8');
554
- }
555
- catch {
556
- // best-effort artifact
526
+ const fresh = [];
527
+ for (const [entries, marker] of [
528
+ [crossCutting, undefined],
529
+ [unresolved, 'no task owns this — surfaced at plan time'],
530
+ [
531
+ judgeFlagged.map(q => ({ quote: q, anchor: '' })),
532
+ 'judge-flagged uncovered area, no task owns this — surfaced at plan time'
533
+ ],
534
+ [
535
+ danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
536
+ 'dangling runtime artifact, nothing produces it — surfaced at plan time'
537
+ ]
538
+ ]) {
539
+ for (const e of entries)
540
+ fresh.push({ line: formatEntry(e, marker), key: normalise(e.quote) });
557
541
  }
542
+ await carried.append(cwd, fresh);
558
543
  }
559
544
  /**
560
545
  * The read-only block refine/compose receive when carried requirements exist.
@@ -593,31 +578,30 @@ export function buildRequirementsBlock(requirements) {
593
578
  // assigned to a task must travel INTO that task as verbatim authoritative text,
594
579
  // exactly like the cross-cutting channel that measurably works.
595
580
  const OWNED_REQUIREMENTS_FILE = 'requirements-owned.md';
581
+ /**
582
+ * Uncapped and never appended to — the mapping is recomputed whole per plan
583
+ * round and rewritten by the DETACH/CLAIM passes, so the key is the quote only
584
+ * for the ledger's contract; nothing dedupes through it.
585
+ */
586
+ const ownedLedger = makeLedger({
587
+ file: OWNED_REQUIREMENTS_FILE,
588
+ key: o => normalise(o.quote),
589
+ serialize: o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''}`
590
+ + (o.pending && o.pending.length > 0 ? ` [pending: ${o.pending.join(', ')}]` : '')
591
+ + ` [title: ${o.title.replace(/\n/g, ' ')}]`,
592
+ parse: parseOwnedRequirements
593
+ });
596
594
  export function ownedRequirementsFile(cwd) {
597
- return path.join(tasksDir(cwd), OWNED_REQUIREMENTS_FILE);
595
+ return ownedLedger.path(cwd);
598
596
  }
599
597
  /** Persist the task-mapped requirements (host-side, plan time). Overwrites —
600
598
  * the mapping is recomputed whole per plan round. Best-effort like the carried
601
599
  * artifact. */
602
- export async function writeOwnedRequirements(cwd, owned) {
603
- try {
604
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
605
- const lines = owned.map(o => `OWNED: "${o.quote}"${o.anchor ? ` [anchor: ${o.anchor}]` : ''}`
606
- + (o.pending && o.pending.length > 0 ? ` [pending: ${o.pending.join(', ')}]` : '')
607
- + ` [title: ${o.title.replace(/\n/g, ' ')}]`);
608
- await fsp.writeFile(ownedRequirementsFile(cwd), lines.join('\n') + '\n', 'utf8');
609
- }
610
- catch {
611
- // best-effort artifact
612
- }
600
+ export async function writeOwnedRequirements(cwd, entries) {
601
+ await ownedLedger.write(cwd, entries);
613
602
  }
614
603
  export async function readOwnedRequirements(cwd) {
615
- try {
616
- return parseOwnedRequirements(await fsp.readFile(ownedRequirementsFile(cwd), 'utf8'));
617
- }
618
- catch {
619
- return [];
620
- }
604
+ return ownedLedger.read(cwd);
621
605
  }
622
606
  export function parseOwnedRequirements(text) {
623
607
  const out = [];
@@ -43,9 +43,7 @@
43
43
  * fails, it lands in the ledger like any other task and is never re-spawned, which
44
44
  * is what keeps this from looping.
45
45
  */
46
- import * as fsp from 'node:fs/promises';
47
- import * as path from 'node:path';
48
- import { tasksDir } from './task-io.js';
46
+ import { makeLedger } from './ledger.js';
49
47
  /** A path-like token: at least one directory separator, ending in a file name. */
50
48
  const PATH_TOKEN_RE = /(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g;
51
49
  /**
@@ -219,7 +217,7 @@ function normalisePath(p) {
219
217
  // `.pi-tasks/` — the same durability contract as accept-debt.ts: it survives
220
218
  // discardEdits and the git-state guard, and a resume picks up what a crash left.
221
219
  export function repairQueueFile(cwd) {
222
- return path.join(tasksDir(cwd), REPAIR_QUEUE_FILE);
220
+ return ledger.path(cwd);
223
221
  }
224
222
  function serialize(c) {
225
223
  return [c.file, c.owner, c.blamedTask, c.verifyCommand ?? '', c.defect]
@@ -249,28 +247,22 @@ export function parseRepairQueue(raw) {
249
247
  }
250
248
  return out;
251
249
  }
250
+ /**
251
+ * The queue. Keyed on file + blamed task ("cap 1 repair task per file per run" is
252
+ * enforced at drain; here a re-detection of the same accusation is a return, not a
253
+ * second record — hence `onNoop: 'skip'`).
254
+ */
255
+ const ledger = makeLedger({
256
+ file: REPAIR_QUEUE_FILE,
257
+ max: MAX_QUEUED,
258
+ key: x => `${x.file.toLowerCase()} ${x.blamedTask.toLowerCase()}`,
259
+ serialize,
260
+ parse: parseRepairQueue,
261
+ onNoop: 'skip'
262
+ });
252
263
  /** Append one candidate. Best-effort — the queue never blocks a gate. */
253
264
  export async function recordRepairCandidate(cwd, c) {
254
- try {
255
- const existing = parseRepairQueue(await readQueueRaw(cwd));
256
- const key = (x) => `${x.file.toLowerCase()} ${x.blamedTask.toLowerCase()}`;
257
- if (existing.some(e => key(e) === key(c)))
258
- return;
259
- const kept = [...existing, c].slice(-MAX_QUEUED);
260
- await fsp.mkdir(tasksDir(cwd), { recursive: true });
261
- await fsp.writeFile(repairQueueFile(cwd), kept.map(serialize).join('\n') + '\n', 'utf8');
262
- }
263
- catch {
264
- // best-effort ledger
265
- }
266
- }
267
- async function readQueueRaw(cwd) {
268
- try {
269
- return (await fsp.readFile(repairQueueFile(cwd), 'utf8')).trim();
270
- }
271
- catch {
272
- return '';
273
- }
265
+ await ledger.append(cwd, [c]);
274
266
  }
275
267
  /**
276
268
  * Read the queue and CLEAR it. Draining is what makes the "cap 1 repair task per
@@ -279,16 +271,12 @@ async function readQueueRaw(cwd) {
279
271
  * {@link planHasRepairFor}) or was already covered by one.
280
272
  */
281
273
  export async function drainRepairQueue(cwd) {
282
- const parsed = parseRepairQueue(await readQueueRaw(cwd));
274
+ const parsed = await ledger.read(cwd);
283
275
  if (parsed.length === 0)
284
276
  return [];
285
- try {
286
- await fsp.writeFile(repairQueueFile(cwd), '', 'utf8');
287
- }
288
- catch {
289
- // best-effort; a failed clear at worst re-offers candidates that
290
- // planHasRepairFor then rejects.
291
- }
277
+ // Best-effort clear; a failed clear at worst re-offers candidates that
278
+ // planHasRepairFor then rejects.
279
+ await ledger.write(cwd, []);
292
280
  return parsed;
293
281
  }
294
282
  /**
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The run bracket: what "a command owns the session" MEANS, in one place.
3
+ *
4
+ * Every long-running command — `/task`, `/task-auto`, `/task-auto-resume`,
5
+ * `/task-plan`, and `TaskRunner.run` inside all of them — spends most of its
6
+ * life with the host session idle: the spec phases, the planning children and
7
+ * every gate are child `pi` processes, not host turns. Two things must be true
8
+ * for exactly that window, and both are refcounted because the runs nest
9
+ * (`/task-auto` brackets its loop, each task inside brackets its own run):
10
+ *
11
+ * 1. mid-run input is HELD, not queued or turned into a competing turn
12
+ * (`mid-run-input.ts`: `beginRun`/`endRun`);
13
+ * 2. the raw-stdin interception that makes the terminal behave like the
14
+ * browser is ARMED (`cancel-input.ts`: `armCancelListener`/`disarm…`).
15
+ *
16
+ * Before this module the pair — begin, arm, … finally disarm, end, report the
17
+ * dropped lines — was written out at four sites in two files, and the `finally`
18
+ * halves disagreed on order (the orchestrator disarmed first; `/task-auto` ended
19
+ * first). The order is not observable — both halves are synchronous, the
20
+ * listener only consults `isRunActive()` on a keystroke, and no keystroke can
21
+ * land between two statements of one tick — so there is ONE order here, not an
22
+ * option: stop listening, then release the hold and report what it dropped.
23
+ *
24
+ * The two refcounts stay two, deliberately. `runDepth` is read by the remote
25
+ * bridge (`isRunActive`) with no ctx in hand and by the listener itself;
26
+ * `armed.depth` guards a live stdin subscription that is re-pointed on every
27
+ * session replacement (`rearmCancelListener`) and is armed on its own by the
28
+ * cancel-latency harness and cancel-points tests. Collapsing them means one
29
+ * module's counter driving the other's lifecycle, or a third counter with both
30
+ * modules demoted to flags — a wider change than the drift it prevents. What
31
+ * prevents drift now is that this bracket is the ONLY production caller of
32
+ * either pair.
33
+ */
34
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
35
+ export interface RunBracketOptions {
36
+ /**
37
+ * Delivered when `/task-auto-cancel` is typed mid-run, with the ctx the
38
+ * listener is currently installed on (the captured one goes stale the moment
39
+ * a task replaces the session). The FIRST run to pass one wins for the whole
40
+ * nest — `/task-auto` passes one so its acknowledgement posts through the
41
+ * live ctx; a plain `/task` passes none and lets the generic bridge dispatch
42
+ * handle the command like any other.
43
+ */
44
+ onCancel?: (live: ExtensionCommandContext) => void;
45
+ }
46
+ /**
47
+ * Run `fn` as the owner of the session: hold mid-run input and arm the terminal
48
+ * interception for exactly its duration, then release both — on return AND on
49
+ * throw — and tell the user about any held line that never found a turn.
50
+ * Nests: an inner bracket neither re-renders the surfaces nor un-arms the outer.
51
+ */
52
+ export declare function withRun<T>(ctx: ExtensionCommandContext, opts: RunBracketOptions, fn: () => Promise<T>): Promise<T>;
53
+ export type AnnounceLevel = 'info' | 'warning' | 'error';
54
+ export interface AnnounceOptions {
55
+ /**
56
+ * Also send a web push ("Task finished") to backgrounded devices. Default
57
+ * true — a run's terminal outcome is what a phone left on the desk needs to
58
+ * hear. `/task-plan` passes false: its cancelled/failed endings are the end
59
+ * of a CONVERSATION, before any task exists, and the plan that does hand off
60
+ * gets the run's own push from `/task`.
61
+ */
62
+ push?: boolean;
63
+ }
64
+ /**
65
+ * Say how a run ended, once, on every surface: the terminal toast, the remote
66
+ * session view (an error becomes a persistent red bubble; the rest a transient
67
+ * notify), and — unless opted out — a web push whose body is the exact terminal
68
+ * message, so a backgrounded phone learns the same thing the TUI shows.
69
+ *
70
+ * Terminal points ONLY: the overall `/task-auto` outcome, `/task`'s outcome
71
+ * after its gates, `/task-plan`'s failed or cancelled ending. Never per internal
72
+ * task — those run through `runSingleTask` without `notifyFinish` and stay
73
+ * silent.
74
+ */
75
+ export declare function announceTerminal(ctx: ExtensionCommandContext, message: string, level: AnnounceLevel, opts?: AnnounceOptions): void;
@@ -0,0 +1,41 @@
1
+ import { armCancelListener, disarmCancelListener } from './cancel-input.js';
2
+ import { beginRun, endRun } from './mid-run-input.js';
3
+ import { reportDroppedInput } from './dropped-input.js';
4
+ import { publishLifecycleNotice } from '../remote/bridge.js';
5
+ import { pushNotify } from '../remote/push.js';
6
+ /**
7
+ * Run `fn` as the owner of the session: hold mid-run input and arm the terminal
8
+ * interception for exactly its duration, then release both — on return AND on
9
+ * throw — and tell the user about any held line that never found a turn.
10
+ * Nests: an inner bracket neither re-renders the surfaces nor un-arms the outer.
11
+ */
12
+ export async function withRun(ctx, opts, fn) {
13
+ beginRun();
14
+ armCancelListener(ctx, opts.onCancel);
15
+ try {
16
+ return await fn();
17
+ }
18
+ finally {
19
+ disarmCancelListener();
20
+ reportDroppedInput(endRun(), ctx);
21
+ }
22
+ }
23
+ /**
24
+ * Say how a run ended, once, on every surface: the terminal toast, the remote
25
+ * session view (an error becomes a persistent red bubble; the rest a transient
26
+ * notify), and — unless opted out — a web push whose body is the exact terminal
27
+ * message, so a backgrounded phone learns the same thing the TUI shows.
28
+ *
29
+ * Terminal points ONLY: the overall `/task-auto` outcome, `/task`'s outcome
30
+ * after its gates, `/task-plan`'s failed or cancelled ending. Never per internal
31
+ * task — those run through `runSingleTask` without `notifyFinish` and stay
32
+ * silent.
33
+ */
34
+ export function announceTerminal(ctx, message, level, opts = {}) {
35
+ ctx.ui.notify(message, level);
36
+ // ctx.ui.notify is terminal-only and pushNotify is a backgrounded-device web
37
+ // push — neither shows up in a remote viewer that's watching live.
38
+ publishLifecycleNotice(message, level);
39
+ if (opts.push !== false)
40
+ void pushNotify('Task finished', message, 'pi-end').catch(() => { });
41
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Progress-based runaway guard for phase children — the replacement for a
3
+ * wall-clock cap.
4
+ *
5
+ * WHY NOT SECONDS. PHASE_CHILD_TIMEOUT_MS was sized against "measured HEALTHY
6
+ * planning children" on one local backend: decompose 89s, so 600s looked like a
7
+ * 6x margin. That sizing is not a property of the pathology, it is a property of
8
+ * that day's model, that day's samplers and that day's design doc. Measured on
9
+ * the same 27B backend with reasoning ON (2026-08-17, n=10 replays of one
10
+ * captured auto-decompose request, everything else byte-identical): every single
11
+ * healthy run took 610-927s and produced 26-42 correct titles. The cap would
12
+ * have killed 10 out of 10 GOOD runs. A slower model, a bigger design doc or a
13
+ * longer reasoning budget moves that number again — so any constant in seconds
14
+ * is wrong for someone.
15
+ *
16
+ * WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
17
+ * project size and reasoning budget:
18
+ *
19
+ * 1. NO NEW GROUND. Count CONSECUTIVE tool calls whose RESULT taught the child
20
+ * nothing: an error, or bytes it has already been given. A child paging
21
+ * forward through a 25 KB design file gets different bytes every call and
22
+ * never trips, however slow it is. A child re-opening the same four files
23
+ * trips after NO_PROGRESS_LIMIT of them, however fast it is.
24
+ *
25
+ * Judged on the RESULT, not the arguments, because the arguments lie. The
26
+ * thrash measured on 2026-08-17 was 197 of 200 calls REFUSED by the
27
+ * single-read guard, each at a different rising offset — so by arguments it
28
+ * looked like textbook forward paging, and both an offset rule and the loop
29
+ * detector's path rule waved all 200 through. By result it is 197 identical
30
+ * refusals in a row, which is what it actually was.
31
+ *
32
+ * 2. CONTEXT CHURN. Sum the bytes of tool RESULTS the child has pulled in. Once
33
+ * that exceeds CONTEXT_CHURN_FACTOR times its own context window and it
34
+ * still has not answered, it has necessarily forgotten what it read first
35
+ * and is re-reading to fill a window pi keeps compacting. That is the
36
+ * mx5-n 2026-08-14 shape: 16m23s at 117,370 of a 120,064-token window,
37
+ * ~56k tokens of tool output per minute, forward-paging the whole time so
38
+ * rule 1 alone would not have caught it. The bound scales with the model's
39
+ * OWN window, so a 1M-context model gets a 1M-context allowance.
40
+ *
41
+ * Neither rule can fire on a child that is thinking rather than calling tools:
42
+ * that case is bounded by the model's max tokens (server-enforced) and by the
43
+ * stream watchdog if the stream goes silent. Between the three there is no
44
+ * runaway left that needs a clock.
45
+ *
46
+ * Pure logic, no I/O, no timers. LoopDetector (loop-detector.ts) is the
47
+ * short-window sibling that trips FAST on an exact repeat; this is the
48
+ * whole-run backstop that trips on sustained non-progress.
49
+ */
50
+ import type { LoopHit, ToolCall } from '../shared/child-process.js';
51
+ /**
52
+ * Consecutive no-new-ground tool results before the child is killed.
53
+ *
54
+ * Eight, because the honest reasons to get back something you have already seen
55
+ * are few and bounded: re-checking a file after an edit, a grep that lands in a
56
+ * file already read, a retry after a malformed call, a missing path. A child
57
+ * doing real work interleaves those with progress and resets the counter. In the
58
+ * replayed thrash the counter never resets at all — the observed runs made
59
+ * 188-201 consecutive dead calls. The gap between "a handful" and "two hundred"
60
+ * is wide enough that the exact value is not load-bearing.
61
+ */
62
+ export declare const NO_PROGRESS_LIMIT = 8;
63
+ /**
64
+ * Multiples of the child's OWN context window of tool output it may pull before
65
+ * being called stuck. Two, so a child is allowed to fill its window once and
66
+ * still have a whole window of budget left for legitimate re-reading after pi
67
+ * compacts. Past that it is provably re-reading what it can no longer hold.
68
+ */
69
+ export declare const CONTEXT_CHURN_FACTOR = 2;
70
+ /** Which rule tripped, so the caller can hint at the right mistake. */
71
+ export type StallKind = 'no-new-ground' | 'context-churn';
72
+ export declare class StallDetector {
73
+ private readonly limit;
74
+ private readonly churnFactor;
75
+ /** Every distinct result the child has been handed, for the WHOLE run. */
76
+ private readonly seenResults;
77
+ /** Exact (name, args) keys already issued — the fallback signal for a
78
+ * transport that reports calls but not results. */
79
+ private readonly seenCalls;
80
+ private deadStreak;
81
+ private resultChars;
82
+ private contextWindow;
83
+ constructor(limit?: number, churnFactor?: number);
84
+ /**
85
+ * Record a tool call. Returns a LoopHit (tagged with `stall`) when either
86
+ * rule has tripped, so it rides the kill/restart path the loop detector
87
+ * already has, else null.
88
+ *
89
+ * The verdict is read here but EARNED in noteResult: this is the hook the
90
+ * child runner can kill from, and a result only arrives after its call has
91
+ * been let through.
92
+ */
93
+ record(call: ToolCall): LoopHit | null;
94
+ /**
95
+ * A tool call finished. Its result is what actually entered the context, so
96
+ * it — not the arguments — decides whether the child learned anything. An
97
+ * error, or bytes already handed over earlier in this run, is dead ground.
98
+ */
99
+ noteResult(text: string, isError?: boolean): void;
100
+ /** Latest context-window size reported by the child. 0 until one arrives. */
101
+ noteContext(contextWindow: number): void;
102
+ private churnTripped;
103
+ }
104
+ /**
105
+ * Restart hint for a child killed by the stall detector. Names the specific
106
+ * mistake — re-reading covered ground vs pulling in more than it can hold —
107
+ * because "you ran out of time" (the old wall-clock hint) told a model that was
108
+ * working correctly but slowly to truncate its work for no reason.
109
+ */
110
+ export declare function formatStallHint(kind: StallKind): string;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Progress-based runaway guard for phase children — the replacement for a
3
+ * wall-clock cap.
4
+ *
5
+ * WHY NOT SECONDS. PHASE_CHILD_TIMEOUT_MS was sized against "measured HEALTHY
6
+ * planning children" on one local backend: decompose 89s, so 600s looked like a
7
+ * 6x margin. That sizing is not a property of the pathology, it is a property of
8
+ * that day's model, that day's samplers and that day's design doc. Measured on
9
+ * the same 27B backend with reasoning ON (2026-08-17, n=10 replays of one
10
+ * captured auto-decompose request, everything else byte-identical): every single
11
+ * healthy run took 610-927s and produced 26-42 correct titles. The cap would
12
+ * have killed 10 out of 10 GOOD runs. A slower model, a bigger design doc or a
13
+ * longer reasoning budget moves that number again — so any constant in seconds
14
+ * is wrong for someone.
15
+ *
16
+ * WHAT REPLACES IT. Two bounds, both dimensionless — invariant to model speed,
17
+ * project size and reasoning budget:
18
+ *
19
+ * 1. NO NEW GROUND. Count CONSECUTIVE tool calls whose RESULT taught the child
20
+ * nothing: an error, or bytes it has already been given. A child paging
21
+ * forward through a 25 KB design file gets different bytes every call and
22
+ * never trips, however slow it is. A child re-opening the same four files
23
+ * trips after NO_PROGRESS_LIMIT of them, however fast it is.
24
+ *
25
+ * Judged on the RESULT, not the arguments, because the arguments lie. The
26
+ * thrash measured on 2026-08-17 was 197 of 200 calls REFUSED by the
27
+ * single-read guard, each at a different rising offset — so by arguments it
28
+ * looked like textbook forward paging, and both an offset rule and the loop
29
+ * detector's path rule waved all 200 through. By result it is 197 identical
30
+ * refusals in a row, which is what it actually was.
31
+ *
32
+ * 2. CONTEXT CHURN. Sum the bytes of tool RESULTS the child has pulled in. Once
33
+ * that exceeds CONTEXT_CHURN_FACTOR times its own context window and it
34
+ * still has not answered, it has necessarily forgotten what it read first
35
+ * and is re-reading to fill a window pi keeps compacting. That is the
36
+ * mx5-n 2026-08-14 shape: 16m23s at 117,370 of a 120,064-token window,
37
+ * ~56k tokens of tool output per minute, forward-paging the whole time so
38
+ * rule 1 alone would not have caught it. The bound scales with the model's
39
+ * OWN window, so a 1M-context model gets a 1M-context allowance.
40
+ *
41
+ * Neither rule can fire on a child that is thinking rather than calling tools:
42
+ * that case is bounded by the model's max tokens (server-enforced) and by the
43
+ * stream watchdog if the stream goes silent. Between the three there is no
44
+ * runaway left that needs a clock.
45
+ *
46
+ * Pure logic, no I/O, no timers. LoopDetector (loop-detector.ts) is the
47
+ * short-window sibling that trips FAST on an exact repeat; this is the
48
+ * whole-run backstop that trips on sustained non-progress.
49
+ */
50
+ import { stableStringify } from './loop-detector.js';
51
+ /**
52
+ * Consecutive no-new-ground tool results before the child is killed.
53
+ *
54
+ * Eight, because the honest reasons to get back something you have already seen
55
+ * are few and bounded: re-checking a file after an edit, a grep that lands in a
56
+ * file already read, a retry after a malformed call, a missing path. A child
57
+ * doing real work interleaves those with progress and resets the counter. In the
58
+ * replayed thrash the counter never resets at all — the observed runs made
59
+ * 188-201 consecutive dead calls. The gap between "a handful" and "two hundred"
60
+ * is wide enough that the exact value is not load-bearing.
61
+ */
62
+ export const NO_PROGRESS_LIMIT = 8;
63
+ /**
64
+ * Multiples of the child's OWN context window of tool output it may pull before
65
+ * being called stuck. Two, so a child is allowed to fill its window once and
66
+ * still have a whole window of budget left for legitimate re-reading after pi
67
+ * compacts. Past that it is provably re-reading what it can no longer hold.
68
+ */
69
+ export const CONTEXT_CHURN_FACTOR = 2;
70
+ /** Chars per token. Rough on purpose — the bound is a factor of 2, not a budget. */
71
+ const CHARS_PER_TOKEN = 4;
72
+ export class StallDetector {
73
+ limit;
74
+ churnFactor;
75
+ /** Every distinct result the child has been handed, for the WHOLE run. */
76
+ seenResults = new Set();
77
+ /** Exact (name, args) keys already issued — the fallback signal for a
78
+ * transport that reports calls but not results. */
79
+ seenCalls = new Set();
80
+ deadStreak = 0;
81
+ resultChars = 0;
82
+ contextWindow = 0;
83
+ constructor(limit = NO_PROGRESS_LIMIT, churnFactor = CONTEXT_CHURN_FACTOR) {
84
+ this.limit = limit;
85
+ this.churnFactor = churnFactor;
86
+ }
87
+ /**
88
+ * Record a tool call. Returns a LoopHit (tagged with `stall`) when either
89
+ * rule has tripped, so it rides the kill/restart path the loop detector
90
+ * already has, else null.
91
+ *
92
+ * The verdict is read here but EARNED in noteResult: this is the hook the
93
+ * child runner can kill from, and a result only arrives after its call has
94
+ * been let through.
95
+ */
96
+ record(call) {
97
+ if (this.churnTripped()) {
98
+ return {
99
+ call,
100
+ count: Math.round(this.resultChars / CHARS_PER_TOKEN),
101
+ windowSize: this.contextWindow,
102
+ stall: 'context-churn'
103
+ };
104
+ }
105
+ const key = `${call.name}\x00${stableStringify(call.args)}`;
106
+ // A verbatim repeat is dead ground whatever its result turns out to be,
107
+ // and this is the only signal available if results are not reported.
108
+ if (this.seenCalls.has(key))
109
+ this.deadStreak++;
110
+ this.seenCalls.add(key);
111
+ if (this.deadStreak >= this.limit) {
112
+ return { call, count: this.deadStreak, windowSize: 0, stall: 'no-new-ground' };
113
+ }
114
+ return null;
115
+ }
116
+ /**
117
+ * A tool call finished. Its result is what actually entered the context, so
118
+ * it — not the arguments — decides whether the child learned anything. An
119
+ * error, or bytes already handed over earlier in this run, is dead ground.
120
+ */
121
+ noteResult(text, isError = false) {
122
+ this.resultChars += text.length;
123
+ if (isError || this.seenResults.has(text)) {
124
+ this.deadStreak++;
125
+ return;
126
+ }
127
+ this.seenResults.add(text);
128
+ this.deadStreak = 0;
129
+ }
130
+ /** Latest context-window size reported by the child. 0 until one arrives. */
131
+ noteContext(contextWindow) {
132
+ if (contextWindow > 0)
133
+ this.contextWindow = contextWindow;
134
+ }
135
+ churnTripped() {
136
+ if (this.contextWindow <= 0)
137
+ return false;
138
+ return this.resultChars / CHARS_PER_TOKEN > this.contextWindow * this.churnFactor;
139
+ }
140
+ }
141
+ /**
142
+ * Restart hint for a child killed by the stall detector. Names the specific
143
+ * mistake — re-reading covered ground vs pulling in more than it can hold —
144
+ * because "you ran out of time" (the old wall-clock hint) told a model that was
145
+ * working correctly but slowly to truncate its work for no reason.
146
+ */
147
+ export function formatStallHint(kind) {
148
+ if (kind === 'context-churn') {
149
+ return ('[SYSTEM NOTE: Your previous attempt pulled in more file content than '
150
+ + 'its context window can hold, so the earliest material was dropped and '
151
+ + 'you began re-reading it. Do not re-open files. Read only what you have '
152
+ + 'not read yet, and write your answer from what you have.]');
153
+ }
154
+ return ('[SYSTEM NOTE: Your previous attempt made a run of tool calls that returned '
155
+ + 'nothing you had not already seen — you were re-opening files you had '
156
+ + 'already read. Read each region of a file AT MOST ONCE, and when a file is '
157
+ + 'too large to read whole, page FORWARD through it rather than re-opening '
158
+ + 'the start. Write your answer from what you have gathered.]');
159
+ }