@mjasnikovs/pi-task 0.38.1 → 0.38.3

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 (46) hide show
  1. package/dist/config/config.d.ts +7 -0
  2. package/dist/config/config.js +10 -4
  3. package/dist/config/register.d.ts +37 -0
  4. package/dist/config/register.js +89 -114
  5. package/dist/remote/events.js +0 -3
  6. package/dist/remote/register.js +12 -3
  7. package/dist/task/auto-orchestrator.js +119 -94
  8. package/dist/task/command-run.d.ts +104 -0
  9. package/dist/task/command-run.js +138 -0
  10. package/dist/task/coverage-loop.d.ts +45 -0
  11. package/dist/task/critique-probes.d.ts +82 -0
  12. package/dist/task/critique-probes.js +156 -0
  13. package/dist/task/enforce-guidelines.d.ts +14 -17
  14. package/dist/task/enforce-guidelines.js +44 -31
  15. package/dist/task/final-gate.d.ts +8 -10
  16. package/dist/task/final-gate.js +36 -74
  17. package/dist/task/gate-child.d.ts +104 -0
  18. package/dist/task/gate-child.js +177 -0
  19. package/dist/task/gate-deps.js +57 -205
  20. package/dist/task/orchestrator.js +13 -22
  21. package/dist/task/phases.js +109 -182
  22. package/dist/task/plan-session.d.ts +4 -22
  23. package/dist/task/plan-session.js +4 -33
  24. package/dist/task/question-dialog.d.ts +71 -0
  25. package/dist/task/question-dialog.js +89 -0
  26. package/dist/task/terminal-outcome.d.ts +67 -0
  27. package/dist/task/terminal-outcome.js +76 -0
  28. package/dist/task/type-only-answer.js +2 -3
  29. package/dist/workers/abstention.d.ts +71 -0
  30. package/dist/workers/abstention.js +108 -0
  31. package/dist/workers/docs-chunk.d.ts +74 -0
  32. package/dist/workers/docs-chunk.js +143 -0
  33. package/dist/workers/docs-core.d.ts +10 -1
  34. package/dist/workers/docs-core.js +22 -19
  35. package/dist/workers/docs-index.js +2 -69
  36. package/dist/workers/docs-project.d.ts +15 -1
  37. package/dist/workers/docs-project.js +27 -66
  38. package/dist/workers/fetch-core.d.ts +1 -1
  39. package/dist/workers/fetch-core.js +2 -1
  40. package/dist/workers/pi-worker-core.js +157 -86
  41. package/dist/workers/pi-worker-docs.js +5 -10
  42. package/dist/workers/pi-worker-fetch.js +8 -1
  43. package/dist/workers/typeonly-log.js +2 -10
  44. package/dist/workers/worker-failure.d.ts +91 -0
  45. package/dist/workers/worker-failure.js +82 -0
  46. package/package.json +1 -1
@@ -176,7 +176,16 @@ export declare function docsRaw(input: DocsRawInput): Promise<DocsRawResult>;
176
176
  export declare function docsFocused(input: DocsFocusedInput): Promise<DocsFocusedResult>;
177
177
  export declare function buildPrompt(pkg: ResolvedPackage, query: string, content: string): string;
178
178
  /** Thin wrapper so existing callers using the pkg-based signature still work. */
179
- export declare function formatResultText(pkg: ResolvedPackage, parsed: {
179
+ /**
180
+ * The provenance header a docs answer carries. Takes the HEADER, not a package:
181
+ * the whole body is one string, and the project-source path — which has no
182
+ * package — used to fabricate a `ResolvedPackage`
183
+ * (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
184
+ * this call compile, with three of the five fields existing only for that.
185
+ */
186
+ export declare function formatResultText(header: string, parsed: {
180
187
  answer: string;
181
188
  excerpt?: string;
182
189
  }, verified: boolean | undefined): string;
190
+ /** The header for an npm package answer. */
191
+ export declare function packageHeader(pkg: ResolvedPackage): string;
@@ -9,6 +9,7 @@ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
9
9
  import { npmVersionLookup as defaultNpmVersionLookup } from './npm-version.js';
10
10
  import { runChild } from '../shared/child-process.js';
11
11
  import { runFocusedExtraction } from './focused-extractor.js';
12
+ import { buildExtractionPrompt } from './abstention.js';
12
13
  import { formatResultText as formatResultTextShared } from '../shared/child-output.js';
13
14
  const DEFAULT_LIMIT = 8;
14
15
  const DEFAULT_BUDGET = 24_000;
@@ -550,26 +551,28 @@ export async function docsFocused(input) {
550
551
  };
551
552
  }
552
553
  export function buildPrompt(pkg, query, content) {
553
- return (`You answer one question about an npm package, using only the provided content.\n`
554
- + `\n`
555
- + `Rules:\n`
556
- + `1. Output ONLY two tags, in this order, with NO text outside them:\n`
557
- + ` <answer>...your answer...</answer>\n`
558
- + ` <excerpt>...verbatim quote from <package-content>...</excerpt>\n`
559
- + `2. The <excerpt> MUST be copied character-for-character from <package-content>.\n`
560
- + ` Do not paraphrase, translate, or summarise inside <excerpt>.\n`
561
- + `3. Prefer type signatures, function declarations, and code blocks as evidence over prose.\n`
562
- + `4. If the answer is unclear, ambiguous, or absent from <package-content>, write exactly:\n`
563
- + ` <answer>unclear from this package</answer> and put the closest related text in <excerpt>.\n`
564
- + ` Do not guess.\n`
565
- + `5. Be terse. One short paragraph in <answer> max.\n`
566
- + `\n`
567
- + `<package>${pkg.name}@${pkg.version}</package>\n`
568
- + `<question>${query}</question>\n`
569
- + `<package-content>\n${content}\n</package-content>\n`);
554
+ return buildExtractionPrompt({
555
+ kind: 'package',
556
+ subject: 'an npm package',
557
+ tag: 'package',
558
+ identity: `${pkg.name}@${pkg.version}`,
559
+ query,
560
+ content
561
+ });
570
562
  }
571
563
  // ─── Backward-compatible wrappers (thin — delegates to shared/) ─────────────
572
564
  /** Thin wrapper so existing callers using the pkg-based signature still work. */
573
- export function formatResultText(pkg, parsed, verified) {
574
- return formatResultTextShared(`Per ${pkg.name}@${pkg.version}:`, parsed, verified);
565
+ /**
566
+ * The provenance header a docs answer carries. Takes the HEADER, not a package:
567
+ * the whole body is one string, and the project-source path — which has no
568
+ * package — used to fabricate a `ResolvedPackage`
569
+ * (`{name, version: 'local', root, entryDts: null, readme: null}`) purely to make
570
+ * this call compile, with three of the five fields existing only for that.
571
+ */
572
+ export function formatResultText(header, parsed, verified) {
573
+ return formatResultTextShared(header, parsed, verified);
574
+ }
575
+ /** The header for an npm package answer. */
576
+ export function packageHeader(pkg) {
577
+ return `Per ${pkg.name}@${pkg.version}:`;
575
578
  }
@@ -2,10 +2,8 @@ import { createHash } from 'node:crypto';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { isDtsFile } from './docs-resolve.js';
5
- const MAX_CHUNK_BYTES = 8 * 1024;
5
+ import { chunkDeclarations, chunkReadme } from './docs-chunk.js';
6
6
  const ZERO_SEP = Buffer.from([0]);
7
- const DECL_SPLIT_RE = /^(?:export\s+|declare\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
8
- const README_SPLIT_RE = /^#{1,2} /m;
9
7
  function computeContentHash(pkg) {
10
8
  const hash = createHash('sha256');
11
9
  hash.update(Buffer.from(`${pkg.name}@${pkg.version}`, 'utf8'));
@@ -67,71 +65,6 @@ function collectFiles(pkg) {
67
65
  readme: pkg.readme
68
66
  };
69
67
  }
70
- function chunkDts(content, relPath) {
71
- const splits = splitAtMatches(content, new RegExp(DECL_SPLIT_RE.source, 'gm'));
72
- const chunks = [];
73
- for (const part of splits) {
74
- const trimmed = part.trim();
75
- if (!trimmed)
76
- continue;
77
- const prefixed = `// ${relPath}\n${trimmed}`;
78
- if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
79
- for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES)) {
80
- chunks.push(slice);
81
- }
82
- }
83
- else {
84
- chunks.push(prefixed);
85
- }
86
- }
87
- return chunks;
88
- }
89
- function chunkReadme(content) {
90
- const splits = splitAtMatches(content, new RegExp(README_SPLIT_RE.source, 'gm'));
91
- const chunks = [];
92
- for (const part of splits) {
93
- const trimmed = part.replace(/\s+$/, '');
94
- if (!trimmed)
95
- continue;
96
- const headingMatch = /^(#{1,2}) (.+)$/m.exec(trimmed);
97
- const heading = headingMatch ? headingMatch[2] : '(intro)';
98
- const prefixed = `<!-- README: ${heading} -->\n${trimmed}`;
99
- if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
100
- for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
101
- chunks.push(slice);
102
- }
103
- else {
104
- chunks.push(prefixed);
105
- }
106
- }
107
- return chunks;
108
- }
109
- function splitAtMatches(text, re) {
110
- const parts = [];
111
- let lastIndex = 0;
112
- let m;
113
- while ((m = re.exec(text))) {
114
- if (m.index > lastIndex)
115
- parts.push(text.slice(lastIndex, m.index));
116
- lastIndex = m.index;
117
- re.lastIndex = m.index + 1;
118
- }
119
- if (lastIndex < text.length)
120
- parts.push(text.slice(lastIndex));
121
- return parts.length ? parts : [text];
122
- }
123
- function sliceBytes(s, maxBytes) {
124
- const out = [];
125
- let buf = Buffer.from(s, 'utf8');
126
- while (buf.length > maxBytes) {
127
- const slice = buf.subarray(0, maxBytes).toString('utf8');
128
- out.push(slice);
129
- buf = buf.subarray(Buffer.byteLength(slice, 'utf8'));
130
- }
131
- if (buf.length)
132
- out.push(buf.toString('utf8'));
133
- return out;
134
- }
135
68
  function ingestBody(cache, pkg, contentHash) {
136
69
  const inside = cache.db
137
70
  .prepare('SELECT content_hash FROM packages WHERE name = ? AND version = ?')
@@ -156,7 +89,7 @@ function ingestBody(cache, pkg, contentHash) {
156
89
  catch {
157
90
  continue;
158
91
  }
159
- const chunks = chunkDts(raw, rel);
92
+ const chunks = chunkDeclarations(raw, rel);
160
93
  if (!chunks.length)
161
94
  continue;
162
95
  filesIngested++;
@@ -3,6 +3,18 @@ import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
3
3
  import type { RetrievedChunk } from './docs-retrieve.js';
4
4
  export declare function getProjectName(cwd: string): string;
5
5
  export declare function cwdKey(cwd: string): string;
6
+ /**
7
+ * Which source files make up the project.
8
+ *
9
+ * `git ls-files` is the source of truth when there is one — it already knows
10
+ * what is tracked and what `.gitignore` excludes, which no hand-rolled walk gets
11
+ * right — and the walk is the fallback for a directory that is not a repo.
12
+ *
13
+ * Injectable through `projectDocsRaw` because it is the only unmockable
14
+ * dependency left in the docs cluster: without a seam, ANY test of the project
15
+ * path needs a real temp directory, real files on disk, and a machine where git
16
+ * is installed and the temp dir is not itself inside a repo.
17
+ */
6
18
  export declare function getProjectFiles(cwd: string): string[];
7
19
  export declare function getMaxMtime(files: string[]): string;
8
20
  export interface ProjectIndexResult {
@@ -34,5 +46,7 @@ export type ProjectDocsRawResult = {
34
46
  projectName: string;
35
47
  message: string;
36
48
  };
37
- export declare function projectDocsRaw(cache: CacheHandle, cwd: string, query: string, retrieveChunksFn?: typeof defaultRetrieveChunks): ProjectDocsRawResult;
49
+ export declare function projectDocsRaw(cache: CacheHandle, cwd: string, query: string, retrieveChunksFn?: typeof defaultRetrieveChunks,
50
+ /** How to enumerate the project's sources. See getProjectFiles. */
51
+ listFiles?: (cwd: string) => string[]): ProjectDocsRawResult;
38
52
  export declare function buildProjectPrompt(projectName: string, query: string, content: string): string;
@@ -3,10 +3,10 @@ import { spawnSync } from 'node:child_process';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
- const MAX_CHUNK_BYTES = 8 * 1024;
6
+ import { buildExtractionPrompt } from './abstention.js';
7
+ import { chunkDeclarations } from './docs-chunk.js';
7
8
  const DEFAULT_LIMIT = 50;
8
9
  const DEFAULT_BUDGET = 24_000;
9
- const DECL_SPLIT_RE = /^(?:export\s+|declare\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|namespace|module|const|let|var|enum)\s+/m;
10
10
  export function getProjectName(cwd) {
11
11
  try {
12
12
  const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
@@ -19,6 +19,18 @@ export function getProjectName(cwd) {
19
19
  export function cwdKey(cwd) {
20
20
  return createHash('sha256').update(cwd).digest('hex').slice(0, 8);
21
21
  }
22
+ /**
23
+ * Which source files make up the project.
24
+ *
25
+ * `git ls-files` is the source of truth when there is one — it already knows
26
+ * what is tracked and what `.gitignore` excludes, which no hand-rolled walk gets
27
+ * right — and the walk is the fallback for a directory that is not a repo.
28
+ *
29
+ * Injectable through `projectDocsRaw` because it is the only unmockable
30
+ * dependency left in the docs cluster: without a seam, ANY test of the project
31
+ * path needs a real temp directory, real files on disk, and a machine where git
32
+ * is installed and the temp dir is not itself inside a repo.
33
+ */
22
34
  export function getProjectFiles(cwd) {
23
35
  try {
24
36
  const result = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '*.ts', '*.tsx'], { cwd, encoding: 'utf8', timeout: 5000 });
@@ -72,50 +84,6 @@ export function getMaxMtime(files) {
72
84
  }
73
85
  return String(Math.floor(max));
74
86
  }
75
- function chunkTs(content, relPath) {
76
- const splits = splitAtMatches(content, new RegExp(DECL_SPLIT_RE.source, 'gm'));
77
- const chunks = [];
78
- for (const part of splits) {
79
- const trimmed = part.trim();
80
- if (!trimmed)
81
- continue;
82
- const prefixed = `// ${relPath}\n${trimmed}`;
83
- if (Buffer.byteLength(prefixed, 'utf8') > MAX_CHUNK_BYTES) {
84
- for (const slice of sliceBytes(prefixed, MAX_CHUNK_BYTES))
85
- chunks.push(slice);
86
- }
87
- else {
88
- chunks.push(prefixed);
89
- }
90
- }
91
- return chunks;
92
- }
93
- function splitAtMatches(text, re) {
94
- const parts = [];
95
- let lastIndex = 0;
96
- let m;
97
- while ((m = re.exec(text))) {
98
- if (m.index > lastIndex)
99
- parts.push(text.slice(lastIndex, m.index));
100
- lastIndex = m.index;
101
- re.lastIndex = m.index + 1;
102
- }
103
- if (lastIndex < text.length)
104
- parts.push(text.slice(lastIndex));
105
- return parts.length ? parts : [text];
106
- }
107
- function sliceBytes(s, maxBytes) {
108
- const out = [];
109
- let buf = Buffer.from(s, 'utf8');
110
- while (buf.length > maxBytes) {
111
- const slice = buf.subarray(0, maxBytes).toString('utf8');
112
- out.push(slice);
113
- buf = buf.subarray(Buffer.byteLength(slice, 'utf8'));
114
- }
115
- if (buf.length)
116
- out.push(buf.toString('utf8'));
117
- return out;
118
- }
119
87
  export function ensureProjectIndexed(cache, name, version, files, cwd) {
120
88
  const existing = cache.db
121
89
  .prepare('SELECT content_hash FROM packages WHERE name = ? AND version = ?')
@@ -140,7 +108,7 @@ export function ensureProjectIndexed(cache, name, version, files, cwd) {
140
108
  continue;
141
109
  }
142
110
  const rel = path.relative(cwd, abs);
143
- const chunks = chunkTs(raw, rel);
111
+ const chunks = chunkDeclarations(raw, rel);
144
112
  if (!chunks.length)
145
113
  continue;
146
114
  filesIngested++;
@@ -160,10 +128,12 @@ export function ensureProjectIndexed(cache, name, version, files, cwd) {
160
128
  throw err;
161
129
  }
162
130
  }
163
- export function projectDocsRaw(cache, cwd, query, retrieveChunksFn = defaultRetrieveChunks) {
131
+ export function projectDocsRaw(cache, cwd, query, retrieveChunksFn = defaultRetrieveChunks,
132
+ /** How to enumerate the project's sources. See getProjectFiles. */
133
+ listFiles = getProjectFiles) {
164
134
  const projectName = getProjectName(cwd);
165
135
  const cacheKey = `project:${cwdKey(cwd)}`;
166
- const files = getProjectFiles(cwd);
136
+ const files = listFiles(cwd);
167
137
  const version = getMaxMtime(files);
168
138
  let indexResult;
169
139
  try {
@@ -229,21 +199,12 @@ export function projectDocsRaw(cache, cwd, query, retrieveChunksFn = defaultRetr
229
199
  };
230
200
  }
231
201
  export function buildProjectPrompt(projectName, query, content) {
232
- return (`You answer one question about a local project's source code, using only the provided content.\n`
233
- + `\n`
234
- + `Rules:\n`
235
- + `1. Output ONLY two tags, in this order, with NO text outside them:\n`
236
- + ` <answer>...your answer...</answer>\n`
237
- + ` <excerpt>...verbatim quote from <project-content>...</excerpt>\n`
238
- + `2. The <excerpt> MUST be copied character-for-character from <project-content>.\n`
239
- + ` Do not paraphrase, translate, or summarise inside <excerpt>.\n`
240
- + `3. Prefer type signatures, function declarations, and code blocks as evidence over prose.\n`
241
- + `4. If the answer is unclear, ambiguous, or absent from <project-content>, write exactly:\n`
242
- + ` <answer>unclear from this project</answer> and put the closest related text in <excerpt>.\n`
243
- + ` Do not guess.\n`
244
- + `5. Be terse. One short paragraph in <answer> max.\n`
245
- + `\n`
246
- + `<project>${projectName}</project>\n`
247
- + `<question>${query}</question>\n`
248
- + `<project-content>\n${content}\n</project-content>\n`);
202
+ return buildExtractionPrompt({
203
+ kind: 'project',
204
+ subject: "a local project's source code",
205
+ tag: 'project',
206
+ identity: projectName,
207
+ query,
208
+ content
209
+ });
249
210
  }
@@ -2,7 +2,7 @@ import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
2
  import type { SpawnFn } from '../shared/child-process.js';
3
3
  import { type ExcerptVerification } from '../shared/child-output.js';
4
4
  /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
5
- export declare const UNCLEAR_ANSWER = "unclear from this page";
5
+ export declare const UNCLEAR_ANSWER: string;
6
6
  export declare const NOT_COVERED_ANSWER = "not covered by this page";
7
7
  export declare function normaliseSourceUrl(url: string): string;
8
8
  export interface FetchRawInput {
@@ -1,12 +1,13 @@
1
1
  import { fetchAndClean as defaultFetchAndClean } from './html-clean.js';
2
2
  import { runFocusedExtraction } from './focused-extractor.js';
3
+ import { abstentionSentence } from './abstention.js';
3
4
  import { formatResultText as formatResultTextShared } from '../shared/child-output.js';
4
5
  const CONTENT_BUDGET = 30_000;
5
6
  const HEAD_CHARS = 25_000;
6
7
  const TAIL_CHARS = 5_000;
7
8
  const TRUNCATION_MARKER = '\n\n[...page continues, truncated...]\n\n';
8
9
  /** The exact non-answers the child is instructed to emit, matched at the tool layer. */
9
- export const UNCLEAR_ANSWER = 'unclear from this page';
10
+ export const UNCLEAR_ANSWER = abstentionSentence('page');
10
11
  export const NOT_COVERED_ANSWER = 'not covered by this page';
11
12
  /**
12
13
  * Rule 6 asks the child to write the sentinel and NOTHING else, so the sentinel is the whole
@@ -255,6 +255,122 @@ export function commandCeilingForAttempt(baseMs, priorHangs) {
255
255
  const floor = Math.min(baseMs, 30_000);
256
256
  return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
257
257
  }
258
+ /**
259
+ * The restart ladder, in precedence order. FIRST MATCH WINS.
260
+ *
261
+ * Read the `!loopHit` guards as "a loop kill outranks me even when it has no
262
+ * budget left". They are not redundant with row order: when a loop is detected
263
+ * but the shared budget is spent, row 1 declines, and without those guards row 2
264
+ * or 4 would then restart the same runaway child under a hint that does not
265
+ * describe why it died.
266
+ *
267
+ * The whole ritual — check the budget, set the hint, spend the counters, record
268
+ * and announce the discarded attempt, sleep, re-spawn — belongs to the loop in
269
+ * `runWorker`, so a new failure mode is one row here and cannot be added without
270
+ * becoming visible in `restarts`.
271
+ */
272
+ const RESTART_RULES = [
273
+ {
274
+ // A loop-kill gets the same restart-with-hint treatment every other phase
275
+ // already gets (runPhaseWithLoopGuard) — name the offending call so the
276
+ // re-spawn avoids it. Bounded by the shared restart budget.
277
+ reason: 'loop',
278
+ detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
279
+ { detail: `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}` }
280
+ : null,
281
+ hint: s => formatLoopHint(s.loopHit),
282
+ counters: { shared: true }
283
+ },
284
+ {
285
+ // A hung COMMAND is restartable too, on the same budget, but checked
286
+ // before the whole-worker timeout because its hint is the specific one:
287
+ // bound the command. (The two can't be confused — a watchdog kill leaves
288
+ // timeout.timedOut() false, since that flag tracks only its own timer.)
289
+ reason: 'command-timeout',
290
+ detect: s => s.commandKill && !s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
291
+ {
292
+ detail: `${s.commandKill.toolName} > ${s.commandKill.timeoutMs}ms`
293
+ + (s.commandKill.detail ? `: ${s.commandKill.detail}` : '')
294
+ }
295
+ : null,
296
+ hint: s => commandTimeoutHint(s.commandKill.toolName, s.commandKill.timeoutMs, {
297
+ commandDetail: s.commandKill.detail,
298
+ // Nothing reverts the tree between attempts, so a child that can
299
+ // mutate it (edit/write, or bash side effects) must not be told
300
+ // its previous attempt left no trace. Same capability test the
301
+ // gate logger uses — decided by tools, not by phase.
302
+ editsMayPersist: /\b(?:edit|bash|write)\b/.test(s.tools)
303
+ }),
304
+ counters: { shared: true, hang: true }
305
+ },
306
+ {
307
+ // A hung model stream is restartable on the same budget. Checked before
308
+ // the wall-clock timeout because it is the more specific diagnosis (and
309
+ // its hint does not blame the model: nothing it did caused the hang).
310
+ reason: 'stream-stall',
311
+ detect: s => s.streamStalled && !s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
312
+ { detail: `idle ${s.streamStalled.idleMs}ms` }
313
+ : null,
314
+ hint: s => streamStallHint(s.streamStalled.idleMs),
315
+ counters: { shared: true }
316
+ },
317
+ {
318
+ // A wall-clock timeout (the backstop for varied thrash the exact-match
319
+ // detector misses) is also restartable, sharing the same budget. Skip when
320
+ // a loop also tripped — the loop hint above is more specific.
321
+ reason: 'worker-timeout',
322
+ detect: s => s.timedOut && !s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
323
+ // The EFFECTIVE cap, which the SCALE arm moves — reporting the
324
+ // configured one would misname why this attempt died.
325
+ { detail: `cap ${s.effectiveCapMs}ms` }
326
+ : null,
327
+ hint: () => WORKER_TIMEOUT_HINT,
328
+ counters: { shared: true }
329
+ },
330
+ {
331
+ // A connection-class model error is restartable on the same budget, exactly
332
+ // as runPhaseWithLoopGuard already treats it — a research worker had no such
333
+ // retry, so one dropped fetch failed the whole task at research while the
334
+ // identical blip in refine/compose was absorbed.
335
+ //
336
+ // What this can and cannot buy, measured (flaky proxy in front of the local
337
+ // llama-server, dropping every connection for a fixed outage window): pi
338
+ // retries a failed turn itself, 4 attempts over ~15s, and a run that
339
+ // recovers no longer reports modelError at all (see JsonEventSink). So a
340
+ // surfaced connection error means pi's own ~15s budget is already spent, and
341
+ // a re-spawn only helps when the outage outlasts it. It does: at a 20s
342
+ // outage the baseline never recovered and this policy always did, 0/8 → 8/8
343
+ // (Fisher p=0.00016), and the same at 35s. Below ~15s pi absorbs it alone —
344
+ // 8/8 both arms, so the retry neither helps nor costs there. Beyond ~46s
345
+ // (three spawns' combined budget) both arms fail. The price is paid only on
346
+ // a backend that is really gone: time-to-report goes ~15s → ~46s. Re-run:
347
+ // scripts/connection-retry-ab.ts.
348
+ //
349
+ // Connection class ONLY. Auth, bad request and context overflow still fail
350
+ // fast: re-issuing the same request cannot fix them, so spending the budget
351
+ // would only delay the report.
352
+ reason: 'connection-error',
353
+ detect: s => (s.modelError !== undefined
354
+ && isConnectionError(s.modelError)
355
+ && s.restartBudgetSpent < MAX_LOOP_RESTARTS
356
+ && s.connRetries < s.connectionRetries) ?
357
+ { detail: s.modelError.slice(0, 120) }
358
+ : null,
359
+ counters: { shared: true, connection: true },
360
+ backoffMs: s => connectionRetryBackoffMs(s.connRetries)
361
+ },
362
+ {
363
+ // Only reached on a clean, complete run — see how `leaked` is computed. A
364
+ // non-zero exit or abort yields partial text the caller already handles,
365
+ // and detecting there would just mislabel the real failure.
366
+ reason: 'leaked-tool-call',
367
+ detect: s => s.leaked && s.leakRetries < MAX_LEAK_RETRIES ?
368
+ { detail: s.leaked.trim().slice(0, 80) }
369
+ : null,
370
+ hint: s => leakedToolCallHint(s.leaked),
371
+ counters: { leak: true }
372
+ }
373
+ ];
258
374
  /**
259
375
  * Build the child-side command watchdog for ONE attempt: a per-tool-call timer
260
376
  * machine (shared with the main session) whose `onFire` aborts `signal`, which
@@ -479,97 +595,52 @@ export async function runWorker(input) {
479
595
  const timedOut = timeout.timedOut();
480
596
  const commandKill = cmdWatch?.killed();
481
597
  const streamStalled = result.streamStalled;
482
- // A loop-kill gets the same restart-with-hint treatment every other phase
483
- // already gets (runPhaseWithLoopGuard) — name the offending call so the
484
- // re-spawn avoids it. Bounded by the shared restart budget.
485
- if (loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
486
- hint = formatLoopHint(loopHit);
487
- restartBudgetSpent++;
488
- noteRestart('loop', `${loopHit.call.name} ×${loopHit.count}/${loopHit.windowSize}`);
489
- continue;
490
- }
491
- // A hung COMMAND is restartable too, on the same budget, but checked
492
- // before the whole-worker timeout because its hint is the specific one:
493
- // bound the command. (The two can't be confused — a watchdog kill leaves
494
- // timeout.timedOut() false, since that flag tracks only its own timer.)
495
- if (commandKill && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
496
- hint = commandTimeoutHint(commandKill.toolName, commandKill.timeoutMs, {
497
- commandDetail: commandKill.detail,
498
- // Nothing reverts the tree between attempts, so a child that can
499
- // mutate it (edit/write, or bash side effects) must not be told
500
- // its previous attempt left no trace. Same capability test the
501
- // gate logger uses — decided by tools, not by phase.
502
- editsMayPersist: /\b(?:edit|bash|write)\b/.test(tools)
503
- });
504
- restartBudgetSpent++;
505
- hangKills++;
506
- noteRestart('command-timeout', `${commandKill.toolName} > ${commandKill.timeoutMs}ms`
507
- + (commandKill.detail ? `: ${commandKill.detail}` : ''));
508
- continue;
509
- }
510
- // A hung model stream is restartable on the same budget. Checked before
511
- // the wall-clock timeout because it is the more specific diagnosis (and
512
- // its hint does not blame the model: nothing it did caused the hang).
513
- if (streamStalled && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
514
- hint = streamStallHint(streamStalled.idleMs);
515
- restartBudgetSpent++;
516
- noteRestart('stream-stall', `idle ${streamStalled.idleMs}ms`);
517
- continue;
518
- }
519
- // A wall-clock timeout (the backstop for varied thrash the exact-match
520
- // detector misses) is also restartable, sharing the same budget. Skip when
521
- // a loop also tripped — the loop hint above is more specific.
522
- if (timedOut && !loopHit && restartBudgetSpent < MAX_LOOP_RESTARTS) {
523
- hint = WORKER_TIMEOUT_HINT;
524
- restartBudgetSpent++;
525
- // The EFFECTIVE cap, which the SCALE arm moves — reporting the
526
- // configured one would misname why this attempt died.
527
- noteRestart('worker-timeout', `cap ${effectiveCapMs}ms`);
528
- continue;
529
- }
530
- // A connection-class model error is restartable on the same budget, exactly
531
- // as runPhaseWithLoopGuard already treats it — a research worker had no such
532
- // retry, so one dropped fetch failed the whole task at research while the
533
- // identical blip in refine/compose was absorbed.
534
- //
535
- // What this can and cannot buy, measured (flaky proxy in front of the local
536
- // llama-server, dropping every connection for a fixed outage window): pi
537
- // retries a failed turn itself, 4 attempts over ~15s, and a run that
538
- // recovers no longer reports modelError at all (see JsonEventSink). So a
539
- // surfaced connection error means pi's own ~15s budget is already spent, and
540
- // a re-spawn only helps when the outage outlasts it. It does: at a 20s
541
- // outage the baseline never recovered and this policy always did, 0/8 → 8/8
542
- // (Fisher p=0.00016), and the same at 35s. Below ~15s pi absorbs it alone —
543
- // 8/8 both arms, so the retry neither helps nor costs there. Beyond ~46s
544
- // (three spawns' combined budget) both arms fail. The price is paid only on
545
- // a backend that is really gone: time-to-report goes ~15s → ~46s. Re-run:
546
- // scripts/connection-retry-ab.ts.
547
- //
548
- // Connection class ONLY. Auth, bad request and context overflow still fail
549
- // fast: re-issuing the same request cannot fix them, so spending the budget
550
- // would only delay the report.
551
- if (result.modelError
552
- && isConnectionError(result.modelError)
553
- && restartBudgetSpent < MAX_LOOP_RESTARTS
554
- && connRetries < (input.connectionRetries ?? MAX_LOOP_RESTARTS)) {
555
- // Noted BEFORE the backoff sleep, so the record's wallMs stays the
556
- // attempt's own clock; the sleep lands in totalWallMs, where it belongs.
557
- noteRestart('connection-error', result.modelError.slice(0, 120));
558
- await (input.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(connRetries));
559
- restartBudgetSpent++;
560
- connRetries++;
561
- continue;
562
- }
563
598
  // Only treat output as a leak on a clean, complete run — a non-zero exit
564
599
  // or abort yields partial text the caller already handles, and detecting
565
600
  // there would just mislabel the real failure.
566
601
  const leaked = result.exitCode === 0 && !result.aborted ? detectLeakedToolCall(text) : null;
567
- if (leaked && leakRetries < MAX_LEAK_RETRIES) {
568
- hint = leakedToolCallHint(leaked);
569
- leakRetries++;
570
- noteRestart('leaked-tool-call', leaked.trim().slice(0, 80));
571
- continue;
602
+ // THE RESTART LADDER. Precedence is RESTART_RULES' row order; this loop
603
+ // owns the ritual every rule used to repeat: budget, hint, counters,
604
+ // record-and-announce, backoff, re-spawn.
605
+ const state = {
606
+ ...(loopHit ? { loopHit } : {}),
607
+ ...(commandKill ? { commandKill } : {}),
608
+ ...(streamStalled ? { streamStalled } : {}),
609
+ timedOut,
610
+ ...(result.modelError !== undefined ? { modelError: result.modelError } : {}),
611
+ leaked,
612
+ effectiveCapMs,
613
+ tools,
614
+ restartBudgetSpent,
615
+ connRetries,
616
+ connectionRetries: input.connectionRetries ?? MAX_LOOP_RESTARTS,
617
+ leakRetries
618
+ };
619
+ let restarted = false;
620
+ for (const rule of RESTART_RULES) {
621
+ const hit = rule.detect(state);
622
+ if (!hit)
623
+ continue;
624
+ if (rule.hint)
625
+ hint = rule.hint(state);
626
+ if (rule.counters.shared)
627
+ restartBudgetSpent++;
628
+ if (rule.counters.leak)
629
+ leakRetries++;
630
+ if (rule.counters.hang)
631
+ hangKills++;
632
+ if (rule.counters.connection)
633
+ connRetries++;
634
+ // Noted BEFORE any backoff sleep, so the record's wallMs stays the
635
+ // attempt's own clock; the sleep lands in totalWallMs, where it belongs.
636
+ noteRestart(rule.reason, hit.detail);
637
+ if (rule.backoffMs)
638
+ await (input.sleepFor ?? defaultSleep)(rule.backoffMs(state));
639
+ restarted = true;
640
+ break;
572
641
  }
642
+ if (restarted)
643
+ continue;
573
644
  // SALVAGE. The run used to return the LAST attempt's text unconditionally,
574
645
  // so a worker whose final attempt was killed early reported nothing at all
575
646
  // — even when a discarded attempt had produced a usable answer that was