@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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
for (const
|
|
533
|
-
|
|
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
|
|
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,
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
274
|
+
const parsed = await ledger.read(cwd);
|
|
283
275
|
if (parsed.length === 0)
|
|
284
276
|
return [];
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|