@mjasnikovs/pi-task 0.38.10 → 0.38.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -3
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +93 -116
- package/dist/task/boot-probe.d.ts +298 -0
- package/dist/task/boot-probe.js +806 -0
- package/dist/task/child-runner.d.ts +56 -25
- package/dist/task/child-runner.js +65 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +38 -432
- package/dist/task/final-gate.js +105 -1213
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/launch-manifest.d.ts +5 -0
- package/dist/task/launch-manifest.js +21 -0
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +70 -297
- package/dist/task/phases.d.ts +18 -0
- package/dist/task/phases.js +4 -3
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/task-gates.d.ts +69 -0
- package/dist/task/task-gates.js +114 -90
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/docs-core.d.ts +0 -4
- package/dist/workers/docs-core.js +10 -34
- package/dist/workers/docs-project.js +3 -3
- package/dist/workers/docs-resolve.d.ts +18 -0
- package/dist/workers/docs-resolve.js +39 -0
- package/dist/workers/docs-retrieve.d.ts +13 -0
- package/dist/workers/docs-retrieve.js +17 -2
- package/dist/workers/fetch-core.d.ts +0 -4
- package/dist/workers/fetch-core.js +2 -5
- package/dist/workers/phantom-imports.d.ts +3 -3
- package/dist/workers/phantom-imports.js +16 -29
- package/dist/workers/pi-worker-docs.d.ts +49 -0
- package/dist/workers/pi-worker-docs.js +33 -9
- package/dist/workers/pi-worker-fetch.d.ts +18 -0
- package/dist/workers/pi-worker-fetch.js +19 -4
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* /task receives that a bare /task would not is the decisions block.
|
|
21
21
|
*/
|
|
22
22
|
import * as path from 'node:path';
|
|
23
|
-
import {
|
|
23
|
+
import { prependHint, USER_CANCELLED } from './child-runner.js';
|
|
24
24
|
import { PLAN_QUESTION_PROMPT, PLAN_ANSWER_PROMPT } from './plan-prompts.js';
|
|
25
25
|
import { runPlanSession, ASK_TITLE } from './plan-session.js';
|
|
26
26
|
import { allocatePlanId, buildPlanBody, buildHandoffPrompt, formatPlanDecisions } from './plan-io.js';
|
|
@@ -32,12 +32,13 @@ import { deriveTitle } from './parsers.js';
|
|
|
32
32
|
import { renderInlineMarkdown } from './inline-markdown.js';
|
|
33
33
|
import { expandFeatureMentions } from './auto-orchestrator.js';
|
|
34
34
|
import { runSingleTask, runGatedTask } from './orchestrator.js';
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
35
|
+
import { SessionUI, publishViewer, publishLifecycleNotice, registerBridgeCommand } from '../remote/bridge.js';
|
|
36
|
+
import { withRun, announceTerminal } from './run-bracket.js';
|
|
37
37
|
import { getConfig } from '../config/config.js';
|
|
38
38
|
import { isYoloMode } from './yolo.js';
|
|
39
39
|
import { gateDebugWriter } from './debug-log.js';
|
|
40
|
-
import { getParentContextWindow
|
|
40
|
+
import { getParentContextWindow } from './context-usage.js';
|
|
41
|
+
import { ChildStatus, runPlanningChild, statusCallbacks } from './child-status.js';
|
|
41
42
|
import * as fsp from 'node:fs/promises';
|
|
42
43
|
/** Loader labels for the two planning children. */
|
|
43
44
|
const PLAN_STEPS = {
|
|
@@ -52,10 +53,8 @@ const PLAN_STEPS = {
|
|
|
52
53
|
export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
53
54
|
const ui = new SessionUI(ctx);
|
|
54
55
|
const theme = ctx.ui.theme;
|
|
55
|
-
let lastLine;
|
|
56
56
|
let status;
|
|
57
|
-
|
|
58
|
-
const parentContextWindow = getParentContextWindow(ctx);
|
|
57
|
+
const childStatus = new ChildStatus({ parentContextWindow: getParentContextWindow(ctx) });
|
|
59
58
|
const title = deriveTitle(task);
|
|
60
59
|
const logDebug = gateDebugWriter((msg) => {
|
|
61
60
|
const line = `${new Date().toISOString()} ${msg}\n`;
|
|
@@ -65,12 +64,7 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
65
64
|
cwd,
|
|
66
65
|
taskId: planId,
|
|
67
66
|
signal,
|
|
68
|
-
|
|
69
|
-
lastLine = line;
|
|
70
|
-
},
|
|
71
|
-
onContextUsage: snapshot => {
|
|
72
|
-
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
73
|
-
},
|
|
67
|
+
...statusCallbacks(childStatus),
|
|
74
68
|
...(logDebug && { logDebug })
|
|
75
69
|
};
|
|
76
70
|
/**
|
|
@@ -82,25 +76,23 @@ export function buildPlanDeps(ctx, cwd, planId, task, signal) {
|
|
|
82
76
|
* the plan file itself is written.
|
|
83
77
|
*/
|
|
84
78
|
const child = async (name, prompt) => {
|
|
85
|
-
lastLine = undefined;
|
|
86
|
-
contextUsage = undefined;
|
|
87
|
-
const startedAt = Date.now();
|
|
88
|
-
const stopLoader = startAutoLoader(ctx, () => ({
|
|
89
|
-
command: '/task-plan',
|
|
90
|
-
title,
|
|
91
|
-
step: status ?? PLAN_STEPS[name] ?? name,
|
|
92
|
-
stepNum: 1,
|
|
93
|
-
stepTotal: 1,
|
|
94
|
-
startedAt,
|
|
95
|
-
lastLine,
|
|
96
|
-
contextUsage
|
|
97
|
-
}));
|
|
98
79
|
const before = await collectTreeChanges(cwd, signal).catch(() => null);
|
|
99
80
|
try {
|
|
100
|
-
return await
|
|
81
|
+
return await runPlanningChild({
|
|
82
|
+
ctx,
|
|
83
|
+
status: childStatus,
|
|
84
|
+
phaseDeps,
|
|
85
|
+
name,
|
|
86
|
+
tools: PLAN_TOOLS,
|
|
87
|
+
prompt,
|
|
88
|
+
loader: {
|
|
89
|
+
command: '/task-plan',
|
|
90
|
+
title,
|
|
91
|
+
step: n => ({ step: status ?? PLAN_STEPS[n] ?? n, stepNum: 1, stepTotal: 1 })
|
|
92
|
+
}
|
|
93
|
+
});
|
|
101
94
|
}
|
|
102
95
|
finally {
|
|
103
|
-
stopLoader();
|
|
104
96
|
// Outside a git repo `before` is null and there is nothing to compare
|
|
105
97
|
// against — the same degrade every other tree-reading guard here takes.
|
|
106
98
|
if (before) {
|
|
@@ -264,6 +256,16 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
264
256
|
title: deriveTitle(raw)
|
|
265
257
|
};
|
|
266
258
|
await writeTaskFile(cwd, fm, buildPlanBody(task));
|
|
259
|
+
// The plan session owns the session the way a run does: its children run
|
|
260
|
+
// with the host idle, so an unbracketed line typed then went into pi's queue
|
|
261
|
+
// (terminal) or started a competing turn (browser) — the issue #8 shape.
|
|
262
|
+
// /task-auto brackets its planning for the same reason; this one was left
|
|
263
|
+
// out when /task-plan landed. Held lines reach the handed-off /task run's
|
|
264
|
+
// first turn (the bracket nests), or are reported when the plan ends without
|
|
265
|
+
// one. No onCancel: /task-auto-cancel is not this command's to acknowledge.
|
|
266
|
+
await withRun(ctx, {}, () => runPlanCommand(ctx, cwd, planId, task, commandDeps));
|
|
267
|
+
}
|
|
268
|
+
async function runPlanCommand(ctx, cwd, planId, task, commandDeps) {
|
|
267
269
|
const abort = new AbortController();
|
|
268
270
|
let outcome;
|
|
269
271
|
try {
|
|
@@ -275,9 +277,9 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
275
277
|
state: msg === USER_CANCELLED ? 'cancelled' : 'failed',
|
|
276
278
|
reason: msg.slice(0, 200)
|
|
277
279
|
}).catch(() => { });
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
280
|
+
// No push: a plan is a conversation, not a task; the run it hands off
|
|
281
|
+
// to pushes its own ending.
|
|
282
|
+
announceTerminal(ctx, `${planId} stopped — ${msg.slice(0, 160)}`, 'error', { push: false });
|
|
281
283
|
return;
|
|
282
284
|
}
|
|
283
285
|
if (outcome.kind === 'cancelled') {
|
|
@@ -289,8 +291,7 @@ export async function handleTaskPlan(args, ctx, commandDeps = DEFAULT_COMMAND_DE
|
|
|
289
291
|
const line = outcome.entries.length === 0 ?
|
|
290
292
|
`${planId} cancelled — nothing planned.`
|
|
291
293
|
: `${planId} cancelled — ${outcome.entries.length} entr${outcome.entries.length === 1 ? 'y' : 'ies'} kept in .pi-tasks/${planId}.md`;
|
|
292
|
-
ctx
|
|
293
|
-
publishNotify(line, 'warning');
|
|
294
|
+
announceTerminal(ctx, line, 'warning', { push: false });
|
|
294
295
|
return;
|
|
295
296
|
}
|
|
296
297
|
const prompt = buildHandoffPrompt(task, outcome.entries);
|
|
@@ -147,7 +147,7 @@ export declare function ownedRequirementsFile(cwd: string): string;
|
|
|
147
147
|
/** Persist the task-mapped requirements (host-side, plan time). Overwrites —
|
|
148
148
|
* the mapping is recomputed whole per plan round. Best-effort like the carried
|
|
149
149
|
* artifact. */
|
|
150
|
-
export declare function writeOwnedRequirements(cwd: string,
|
|
150
|
+
export declare function writeOwnedRequirements(cwd: string, entries: OwnedRequirement[]): Promise<void>;
|
|
151
151
|
export declare function readOwnedRequirements(cwd: string): Promise<OwnedRequirement[]>;
|
|
152
152
|
export declare function parseOwnedRequirements(text: string): OwnedRequirement[];
|
|
153
153
|
/** The owned entries whose plan title matches THIS task's title (normalised
|
|
@@ -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;
|