@mjasnikovs/pi-task 0.38.23 → 0.38.25

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 (48) hide show
  1. package/README.md +2 -2
  2. package/dist/config/reasoning-args.d.ts +12 -1
  3. package/dist/config/reasoning-args.js +5 -2
  4. package/dist/config/reasoning.d.ts +47 -6
  5. package/dist/config/reasoning.js +84 -9
  6. package/dist/config/register.d.ts +50 -26
  7. package/dist/config/register.js +96 -80
  8. package/dist/shared/reasoning-capability.d.ts +2 -5
  9. package/dist/shared/reasoning-capability.js +31 -4
  10. package/dist/task/auto-orchestrator.d.ts +2 -0
  11. package/dist/task/auto-orchestrator.js +28 -41
  12. package/dist/task/child-runner.d.ts +89 -24
  13. package/dist/task/child-runner.js +67 -46
  14. package/dist/task/gate-child.js +11 -11
  15. package/dist/task/orchestrator.d.ts +14 -20
  16. package/dist/task/orchestrator.js +12 -9
  17. package/dist/task/phases.d.ts +0 -23
  18. package/dist/task/phases.js +48 -464
  19. package/dist/task/question-dialog.d.ts +56 -0
  20. package/dist/task/question-dialog.js +53 -0
  21. package/dist/task/research-fanout-budget.d.ts +20 -0
  22. package/dist/task/research-fanout-budget.js +29 -0
  23. package/dist/task/research-worker.d.ts +183 -0
  24. package/dist/task/research-worker.js +429 -0
  25. package/dist/workers/brave-warning.js +4 -30
  26. package/dist/workers/docs-core.d.ts +8 -4
  27. package/dist/workers/docs-core.js +30 -21
  28. package/dist/workers/docs-lookup.d.ts +72 -0
  29. package/dist/workers/docs-lookup.js +53 -0
  30. package/dist/workers/docs-project.d.ts +9 -0
  31. package/dist/workers/docs-project.js +15 -0
  32. package/dist/workers/pi-worker-core.d.ts +112 -109
  33. package/dist/workers/pi-worker-core.js +33 -48
  34. package/dist/workers/pi-worker-docs.js +27 -31
  35. package/dist/workers/pi-worker.js +6 -0
  36. package/dist/workers/reasoning-warning.d.ts +10 -16
  37. package/dist/workers/reasoning-warning.js +25 -57
  38. package/dist/workers/session-hint.d.ts +37 -0
  39. package/dist/workers/session-hint.js +82 -0
  40. package/dist/workers/worker-failure.d.ts +34 -0
  41. package/dist/workers/worker-failure.js +27 -16
  42. package/dist/workers/worker-kill.d.ts +84 -0
  43. package/dist/workers/worker-kill.js +124 -0
  44. package/dist/workers/worker-profiles.d.ts +314 -0
  45. package/dist/workers/worker-profiles.js +220 -0
  46. package/package.json +1 -1
  47. package/dist/task/reasoning-groups.d.ts +0 -36
  48. package/dist/task/reasoning-groups.js +0 -36
@@ -3,15 +3,15 @@ import { Type } from '@sinclair/typebox';
3
3
  import { Text } from '@earendil-works/pi-tui';
4
4
  import { openCache as defaultOpenCache } from './docs-cache.js';
5
5
  import { retrieveChunks as defaultRetrieveChunks } from './docs-retrieve.js';
6
- import { formatResultText } from '../shared/child-output.js';
7
- import { docsRaw, packageHeader, buildPrompt, buildVersionBanner } from './docs-core.js';
6
+ import { docsLookup } from './docs-lookup.js';
7
+ import { projectCorpus } from './docs-project.js';
8
+ import { docsRaw, packageCorpus, buildVersionBanner } from './docs-core.js';
8
9
  import { formatNpmVersionSection } from './npm-version.js';
9
- import { runFocusedExtraction } from './focused-extractor.js';
10
10
  import { childFailureReason, makeWorkerTool, workerAnswer, workerUnavailable } from './shared.js';
11
11
  import { isTypeOnlyAnswer } from '../task/type-only-answer.js';
12
12
  import { logDocsAnswer } from './typeonly-log.js';
13
13
  import { normalizeQuery } from './research-cache.js';
14
- import { projectDocsRaw, buildProjectPrompt } from './docs-project.js';
14
+ import { projectDocsRaw } from './docs-project.js';
15
15
  import { projectDocsBudget, projectDocsBudgetExhausted } from '../task/research-fanout-budget.js';
16
16
  import { isAbstention } from './abstention.js';
17
17
  import { groupThinkingArgs } from '../config/reasoning-args.js';
@@ -128,20 +128,18 @@ export function registerPiWorkerDocs(pi, internals = {}) {
128
128
  // It was DEAD in production (pi runs under node) and BYPASSED under bun test
129
129
  // (internals.spawn is always injected), i.e. untested, unreachable, and wrong.
130
130
  const spawn = internals.spawn ?? defaultSpawn;
131
- // Both paths below run the SAME extraction against this call's cwd/signal/spawn
132
- // and verify the citation against exactly the content they prompted with; only
133
- // the prompt body and the abort wording differ. (fetch is the site that verifies
134
- // against a superset see FocusedRequest.verifyAgainst.)
135
- const extract = (prompt, content, abortedMessage) => runFocusedExtraction({
136
- prompt,
137
- verifyAgainst: content,
131
+ // Both arms below run the SAME tail concatenate, extract, verify,
132
+ // format through `docsLookup`; only the CORPUS differs. The
133
+ // `extraction` group's level is resolved here so neither the lookup
134
+ // nor the extractor reads ambient config.
135
+ const lookup = (corpus, chunks) => docsLookup({
136
+ corpus,
137
+ chunks,
138
+ query: params.query,
138
139
  cwd: ctx.cwd,
139
140
  signal,
140
141
  spawn,
141
- // The `extraction` group's level. Resolved at the call site so the
142
- // extractor itself never reads ambient config.
143
- thinking: groupThinkingArgs('extraction'),
144
- abortedMessage
142
+ thinking: groupThinkingArgs('extraction')
145
143
  });
146
144
  // ── Project source lookup ───────────────────────────────────────
147
145
  if (params.module === '.') {
@@ -182,12 +180,10 @@ export function registerPiWorkerDocs(pi, internals = {}) {
182
180
  indexedFiles: filesIngested,
183
181
  indexingMs
184
182
  };
185
- const concatenated = chunks.map(c => c.content).join('\n\n');
186
- const extraction = await extract(buildProjectPrompt(projectName, params.query, concatenated), concatenated, 'Project docs lookup aborted.');
187
- if (!extraction.ok)
188
- return docsFailureResult(extraction, baseDetails, '');
189
- const verified = extraction.excerptVerified;
190
- const text = formatResultText(`Per ${projectName} (project source):`, extraction, verified);
183
+ const r = await lookup(projectCorpus(projectName), chunks);
184
+ if (r.kind === 'failed')
185
+ return docsFailureResult(r.extraction, baseDetails, '');
186
+ const { extraction, excerptVerified: verified, body: text } = r;
191
187
  // SAME instrumentation channel as the package path below, extended to the
192
188
  // project-source branch because that branch is the MAJORITY of what
193
189
  // worker:apis asks — 13 of 17 docs calls in run 15's fatal task, 7 of 12 in
@@ -243,14 +239,16 @@ export function registerPiWorkerDocs(pi, internals = {}) {
243
239
  hitCache: rawResult.hitCache,
244
240
  cacheError: rawResult.cacheError,
245
241
  autoInstalled: rawResult.autoInstalled,
242
+ // BUG FIX. Both sibling arms carry the pin and this one dropped
243
+ // it, so a package that WAS auto-installed and then failed to
244
+ // re-resolve lost its `versionSource`/`declaredRange` — the
245
+ // provenance the last defect in this area was about. `docsRaw`
246
+ // sets `autoInstallPin` on three of its five error returns.
247
+ ...pinDetails(rawResult.autoInstallPin),
246
248
  ...npmDetails
247
249
  };
248
250
  return workerUnavailable(npmHeader + rawResult.message, details, 'docs-error');
249
251
  }
250
- if (rawResult.kind === 'not_installed') {
251
- return workerUnavailable(npmHeader
252
- + `Package "${rawResult.pkg}" is not installed and auto-install failed.`, { resolveError: 'not_installed', ...npmDetails }, 'not-installed');
253
- }
254
252
  if (rawResult.kind === 'no_chunks') {
255
253
  const banner = buildVersionBanner(rawResult.autoInstallPin, rawResult.pkg.name, rawResult.pkg.version, ctx.cwd);
256
254
  // The package resolved and genuinely ships nothing to read — an
@@ -280,13 +278,11 @@ export function registerPiWorkerDocs(pi, internals = {}) {
280
278
  ...pinDetails(rawResult.autoInstallPin),
281
279
  ...npmDetails
282
280
  };
283
- const concatenated = chunks.map(c => c.content).join('\n\n');
284
- const extraction = await extract(buildPrompt(pkg, params.query, concatenated), concatenated, 'Docs lookup aborted.');
285
- if (!extraction.ok) {
286
- return docsFailureResult(extraction, baseDetails, versionBanner + npmHeader);
281
+ const r = await lookup(packageCorpus(pkg), chunks);
282
+ if (r.kind === 'failed') {
283
+ return docsFailureResult(r.extraction, baseDetails, versionBanner + npmHeader);
287
284
  }
288
- const verified = extraction.excerptVerified;
289
- const body = formatResultText(packageHeader(pkg), extraction, verified);
285
+ const { extraction, excerptVerified: verified, body, content: concatenated } = r;
290
286
  // F-2: a TYPE-ONLY answer is the dangerous failure. "unclear from this package"
291
287
  // is honest and already escalates; a signature is a well-formed, confident,
292
288
  // on-topic answer that names the very parameter asked about, so the worker
@@ -47,6 +47,12 @@ export function registerPiWorker(pi) {
47
47
  prompt: params.prompt,
48
48
  cwd: ctx.cwd,
49
49
  signal,
50
+ // `adhoc` is every guard at its default — which is what this call
51
+ // site already got by naming none of them. Named now so it is a
52
+ // decision, and so the one asymmetry it carries (a FIXED 240s cap
53
+ // where a research worker gets 240s without progress) is written
54
+ // down. See the `adhoc` row in workers/worker-profiles.ts.
55
+ profile: 'adhoc',
50
56
  thinking: groupThinkingArgs('research')
51
57
  });
52
58
  const details = { exitCode: result.exitCode };
@@ -20,22 +20,9 @@
20
20
  * model and nothing renders at all.
21
21
  */
22
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
23
- import { type PiTaskConfig } from '../config/config.js';
24
- import { REASONING_GROUPS, resolveReasoning } from '../config/reasoning.js';
23
+ import { type GroupSetting, type ReasoningGroup } from '../config/reasoning.js';
25
24
  import { type ReasoningMismatch } from '../shared/reasoning-capability.js';
26
- /** Every group's current setting, in the shape `reasoningMismatches` wants. */
27
- export type GroupSettings = Array<{
28
- group: (typeof REASONING_GROUPS)[number];
29
- setting: ReturnType<typeof resolveReasoning>;
30
- }>;
31
- /**
32
- * Read every group's effective setting from a config.
33
- *
34
- * Takes the config rather than calling `getConfig()` so the caller decides where
35
- * it comes from. A test that has to mutate the live singleton to drive this is a
36
- * test whose result depends on whatever the developer had saved before it ran.
37
- */
38
- export declare function settingsFrom(cfg: PiTaskConfig): GroupSettings;
25
+ import { type ChatTemplateCaps } from '../shared/model-endpoint.js';
39
26
  /**
40
27
  * The warning line for a set of mismatches.
41
28
  *
@@ -61,4 +48,11 @@ export declare function registerReasoningWarning(pi: ExtensionAPI,
61
48
  * `session_start` so a /task-config change since the last session counts.
62
49
  * Injected by tests, which must not depend on the developer's saved config.
63
50
  */
64
- readSettings?: () => GroupSettings): void;
51
+ readSettings?: () => Readonly<Record<ReasoningGroup, GroupSetting>>,
52
+ /**
53
+ * The server-side chat-template probe. Injected so the REFINE path — the
54
+ * only half of this hint that talks to a network — is drivable at all; with
55
+ * the real probe it is reachable only from a model entry carrying a
56
+ * `baseUrl`, which no test model has.
57
+ */
58
+ probe?: (baseUrl: string) => Promise<ChatTemplateCaps | null>): void;
@@ -20,20 +20,11 @@
20
20
  * model and nothing renders at all.
21
21
  */
22
22
  import { getConfig } from '../config/config.js';
23
- import { REASONING_GROUPS, resolveReasoning } from '../config/reasoning.js';
23
+ import { effectiveReasoning } from '../config/reasoning.js';
24
24
  import { reasoningMismatches } from '../shared/reasoning-capability.js';
25
25
  import { probeChatTemplateCaps } from '../shared/model-endpoint.js';
26
+ import { registerSessionHint } from './session-hint.js';
26
27
  const WIDGET_KEY = 'pi-task-reasoning-warning';
27
- /**
28
- * Read every group's effective setting from a config.
29
- *
30
- * Takes the config rather than calling `getConfig()` so the caller decides where
31
- * it comes from. A test that has to mutate the live singleton to drive this is a
32
- * test whose result depends on whatever the developer had saved before it ran.
33
- */
34
- export function settingsFrom(cfg) {
35
- return REASONING_GROUPS.map(group => ({ group, setting: resolveReasoning(group, cfg) }));
36
- }
37
28
  /**
38
29
  * The warning line for a set of mismatches.
39
30
  *
@@ -83,60 +74,37 @@ export function registerReasoningWarning(pi,
83
74
  * `session_start` so a /task-config change since the last session counts.
84
75
  * Injected by tests, which must not depend on the developer's saved config.
85
76
  */
86
- readSettings = () => settingsFrom(getConfig())) {
87
- pi.on('session_start', (_event, ctx) => {
88
- // Terminal-only hint: needs an interactive TUI to render and to catch the
89
- // keystroke that dismisses it.
90
- if (ctx.mode !== 'tui')
91
- return;
77
+ readSettings = () => effectiveReasoning(getConfig()),
78
+ /**
79
+ * The server-side chat-template probe. Injected so the REFINE path the
80
+ * only half of this hint that talks to a network — is drivable at all; with
81
+ * the real probe it is reachable only from a model entry carrying a
82
+ * `baseUrl`, which no test model has.
83
+ */
84
+ probe = probeChatTemplateCaps) {
85
+ registerSessionHint(pi, WIDGET_KEY, ctx => {
92
86
  const model = ctx.model;
93
87
  const mismatches = reasoningMismatches(model, readSettings());
94
88
  if (mismatches.length === 0)
95
- return;
89
+ return null;
96
90
  const base = formatReasoningWarning(model?.name ?? model?.id ?? 'unknown', mismatches);
97
91
  if (base === null)
98
- return;
99
- let unsubscribe = null;
100
- let cleared = false;
101
- const clear = () => {
102
- cleared = true;
103
- try {
104
- ctx.ui.setWidget(WIDGET_KEY, undefined);
105
- }
106
- catch {
107
- /* stale ctx after a session switch — nothing to clear */
108
- }
109
- unsubscribe?.();
110
- unsubscribe = null;
111
- };
112
- const render = (text) => {
113
- try {
114
- ctx.ui.setWidget(WIDGET_KEY, [ctx.ui.theme.fg('warning', text)]);
115
- return true;
116
- }
117
- catch {
118
- return false;
119
- }
120
- };
121
- if (!render(base))
122
- return;
123
- unsubscribe = ctx.ui.onTerminalInput(() => {
124
- clear();
125
- return undefined;
126
- });
92
+ return null;
127
93
  // Fire-and-forget: the server probe only ever REFINES the cause line, so
128
94
  // it must not delay the warning or be able to prevent it. A 2s budget and
129
95
  // a swallowed failure mean a non-llama.cpp backend costs nothing.
130
- if (model?.baseUrl) {
131
- void probeChatTemplateCaps(model.baseUrl)
132
- .then(caps => {
133
- if (cleared || caps === null)
134
- return;
135
- const extra = formatCapabilityConflict(caps.supportsReasoningEffort, model.reasoning);
136
- if (extra)
137
- render(base + extra);
96
+ const baseUrl = model?.baseUrl;
97
+ if (model === undefined || baseUrl === undefined || baseUrl === '')
98
+ return { text: base };
99
+ const declares = model.reasoning;
100
+ return {
101
+ text: base,
102
+ refine: probe(baseUrl).then(caps => {
103
+ if (caps === null)
104
+ return null;
105
+ const extra = formatCapabilityConflict(caps.supportsReasoningEffort, declares);
106
+ return extra === null ? null : base + extra;
138
107
  })
139
- .catch(() => { });
140
- }
108
+ };
141
109
  });
142
110
  }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * A one-line startup hint in the TUI, and the whole widget lifetime around it.
3
+ *
4
+ * WHY IT IS ONE MODULE. Two hints exist (brave-warning, reasoning-warning) and
5
+ * both had written the same ritual out: the `session_start` subscription, the
6
+ * TUI gate, the `setWidget` in a try/catch, the `onTerminalInput` that clears on
7
+ * the first keystroke, the unsubscribe, and the swallow for a stale ctx — down
8
+ * to a byte-identical comment. Two adapters is a real seam, so the ritual lives
9
+ * here once and each hint supplies only its `compose`.
10
+ *
11
+ * The REFINE half is why this is not just deduplication. A hint may learn
12
+ * something after it has painted (the reasoning hint probes the model's server),
13
+ * and the rule that a refinement must never repaint a widget the user already
14
+ * dismissed lived in one closure variable in one of the two files. It is now a
15
+ * property of this module, asserted once.
16
+ */
17
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
18
+ export interface SessionHint {
19
+ /** The line to paint now. */
20
+ text: string;
21
+ /**
22
+ * A later refinement of that line, if this hint has one. Resolving to null —
23
+ * or rejecting — leaves the first line standing, so a refinement can never
24
+ * remove a warning it was only meant to sharpen. It is fire-and-forget: it
25
+ * cannot delay or prevent the first paint, and it is dropped if the user has
26
+ * already cleared the hint.
27
+ */
28
+ refine?: Promise<string | null>;
29
+ }
30
+ /**
31
+ * Register one startup hint.
32
+ *
33
+ * `compose` is the seam: it runs at `session_start` inside the TUI gate and
34
+ * returns the hint, or null to say nothing at all. Everything it returns is
35
+ * text; nothing about widgets, keystrokes or teardown reaches it.
36
+ */
37
+ export declare function registerSessionHint(pi: ExtensionAPI, key: string, compose: (ctx: ExtensionContext) => SessionHint | null): void;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A one-line startup hint in the TUI, and the whole widget lifetime around it.
3
+ *
4
+ * WHY IT IS ONE MODULE. Two hints exist (brave-warning, reasoning-warning) and
5
+ * both had written the same ritual out: the `session_start` subscription, the
6
+ * TUI gate, the `setWidget` in a try/catch, the `onTerminalInput` that clears on
7
+ * the first keystroke, the unsubscribe, and the swallow for a stale ctx — down
8
+ * to a byte-identical comment. Two adapters is a real seam, so the ritual lives
9
+ * here once and each hint supplies only its `compose`.
10
+ *
11
+ * The REFINE half is why this is not just deduplication. A hint may learn
12
+ * something after it has painted (the reasoning hint probes the model's server),
13
+ * and the rule that a refinement must never repaint a widget the user already
14
+ * dismissed lived in one closure variable in one of the two files. It is now a
15
+ * property of this module, asserted once.
16
+ */
17
+ /**
18
+ * Register one startup hint.
19
+ *
20
+ * `compose` is the seam: it runs at `session_start` inside the TUI gate and
21
+ * returns the hint, or null to say nothing at all. Everything it returns is
22
+ * text; nothing about widgets, keystrokes or teardown reaches it.
23
+ */
24
+ export function registerSessionHint(pi, key, compose) {
25
+ pi.on('session_start', (_event, ctx) => {
26
+ // Terminal-only hint: needs an interactive TUI to render and to catch the
27
+ // keystroke that dismisses it.
28
+ if (ctx.mode !== 'tui')
29
+ return;
30
+ const hint = compose(ctx);
31
+ if (hint === null)
32
+ return;
33
+ let unsubscribe = null;
34
+ let cleared = false;
35
+ let painted = false;
36
+ const clear = () => {
37
+ cleared = true;
38
+ try {
39
+ ctx.ui.setWidget(key, undefined);
40
+ }
41
+ catch {
42
+ /* stale ctx after a session switch — nothing to clear */
43
+ }
44
+ unsubscribe?.();
45
+ unsubscribe = null;
46
+ };
47
+ const render = (text) => {
48
+ try {
49
+ ctx.ui.setWidget(key, [ctx.ui.theme.fg('warning', text)]);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ };
56
+ // Handled BEFORE the paint can bail. `compose` builds this promise
57
+ // eagerly — the reasoning hint kicks its probe off inside it — so a
58
+ // rejection with the handler attached later is an unhandled rejection on
59
+ // exactly the path the try/catch around `setWidget` exists for: a stale
60
+ // ctx after a session switch. `refine`'s contract says a rejection leaves
61
+ // the first line standing, and that has to hold when there is no first
62
+ // line either.
63
+ if (hint.refine !== undefined) {
64
+ void hint.refine
65
+ .then(text => {
66
+ if (cleared || !painted || text === null)
67
+ return;
68
+ render(text);
69
+ })
70
+ .catch(() => { });
71
+ }
72
+ painted = render(hint.text);
73
+ if (!painted)
74
+ return;
75
+ // Disappear on any interaction — the first raw keystroke clears it.
76
+ // Returning undefined leaves the input untouched (we only observe it).
77
+ unsubscribe = ctx.ui.onTerminalInput(() => {
78
+ clear();
79
+ return undefined;
80
+ });
81
+ });
82
+ }
@@ -26,6 +26,7 @@
26
26
  * caused the bug impossible rather than merely fixed.
27
27
  */
28
28
  import type { LoopHit } from '../task/loop-detector.js';
29
+ import type { WorkerKillId } from './worker-kill.js';
29
30
  /**
30
31
  * The subset of a finished child result this classification reads.
31
32
  *
@@ -83,6 +84,39 @@ export type WorkerFailure = {
83
84
  };
84
85
  /** The `kind` of every row, for exhaustiveness checks in consumers. */
85
86
  export type WorkerFailureKind = WorkerFailure['kind'];
87
+ /**
88
+ * The ordered ladder. FIRST MATCH WINS — row order IS the precedence, and it is
89
+ * the only statement of it in the codebase.
90
+ *
91
+ * The order, and why:
92
+ *
93
+ * 1. `stalled` — no output AND the model endpoint did not answer a probe. The
94
+ * most specific diagnosis there is, and the one most easily lost: the kill
95
+ * aborts, so anything checked after `aborted` never sees it.
96
+ * 2. `command-timeout` — a watchdog kill naming the tool call that hung. Also
97
+ * aborts. Before the wall-clock timeout because it is the narrower cause
98
+ * (the two cannot be confused: a watchdog kill leaves the worker's own
99
+ * timeout flag false).
100
+ * 3. `stream-stall` — a watchdog kill for a model stream that went silent while
101
+ * no tool was running. Sits next to `command-timeout` because it is the same
102
+ * class of event: a watchdog, not the model, ended the attempt.
103
+ * 4. `worker-timeout` — the wall-clock backstop.
104
+ * 5. `loop` — killed for repeating one tool call past threshold. After the
105
+ * timeouts, matching the enforce ladder this replaces; in practice the two
106
+ * cannot both fire, since a loop kill stops the attempt before its own timer
107
+ * can expire.
108
+ * 6. `leaked-tool-call` — the model wrote a call as prose instead of invoking
109
+ * it. Only ever set on an otherwise clean run.
110
+ * 7. `aborted` — no specific cause survived, so this really is a cancel.
111
+ * 8. `exit` — a plain non-zero exit with no kill behind it: a crash.
112
+ */
113
+ export declare const FAILURE_RULES: ReadonlyArray<{
114
+ /** The roster id this row matches. `worker-kill.test.ts` checks the sequence
115
+ * against `FAILURE_ORDER`, so a reordering or an omission is a test failure
116
+ * rather than a silently different precedence. */
117
+ id: WorkerKillId;
118
+ match: (r: WorkerFailureInput) => WorkerFailure | null;
119
+ }>;
86
120
  /**
87
121
  * Classify a finished child. Returns `undefined` when nothing killed it —
88
122
  * which is not the same as "it answered": the text may still be empty, and that
@@ -25,6 +25,8 @@
25
25
  * in every consumer that has not handled it — which is what makes the drift that
26
26
  * caused the bug impossible rather than merely fixed.
27
27
  */
28
+ const _kindsAreKills = true;
29
+ void _kindsAreKills;
28
30
  /**
29
31
  * The ordered ladder. FIRST MATCH WINS — row order IS the precedence, and it is
30
32
  * the only statement of it in the codebase.
@@ -51,21 +53,30 @@
51
53
  * 7. `aborted` — no specific cause survived, so this really is a cancel.
52
54
  * 8. `exit` — a plain non-zero exit with no kill behind it: a crash.
53
55
  */
54
- const FAILURE_RULES = [
55
- r => (r.stalled === true ? { kind: 'stalled' } : null),
56
- r => r.commandTimedOut ?
57
- {
58
- kind: 'command-timeout',
59
- toolName: r.commandTimedOut.toolName,
60
- timeoutMs: r.commandTimedOut.timeoutMs
61
- }
62
- : null,
63
- r => (r.streamStalled ? { kind: 'stream-stall', idleMs: r.streamStalled.idleMs } : null),
64
- r => (r.timedOut === true ? { kind: 'worker-timeout' } : null),
65
- r => (r.loopHit ? { kind: 'loop', hit: r.loopHit } : null),
66
- r => (r.leakedToolCall ? { kind: 'leaked-tool-call', text: String(r.leakedToolCall) } : null),
67
- r => (r.aborted ? { kind: 'aborted' } : null),
68
- r => (r.exitCode !== 0 ? { kind: 'exit', code: r.exitCode } : null)
56
+ export const FAILURE_RULES = [
57
+ { id: 'stalled', match: r => (r.stalled === true ? { kind: 'stalled' } : null) },
58
+ {
59
+ id: 'command-timeout',
60
+ match: r => r.commandTimedOut ?
61
+ {
62
+ kind: 'command-timeout',
63
+ toolName: r.commandTimedOut.toolName,
64
+ timeoutMs: r.commandTimedOut.timeoutMs
65
+ }
66
+ : null
67
+ },
68
+ {
69
+ id: 'stream-stall',
70
+ match: r => r.streamStalled ? { kind: 'stream-stall', idleMs: r.streamStalled.idleMs } : null
71
+ },
72
+ { id: 'worker-timeout', match: r => (r.timedOut === true ? { kind: 'worker-timeout' } : null) },
73
+ { id: 'loop', match: r => (r.loopHit ? { kind: 'loop', hit: r.loopHit } : null) },
74
+ {
75
+ id: 'leaked-tool-call',
76
+ match: r => r.leakedToolCall ? { kind: 'leaked-tool-call', text: String(r.leakedToolCall) } : null
77
+ },
78
+ { id: 'aborted', match: r => (r.aborted ? { kind: 'aborted' } : null) },
79
+ { id: 'exit', match: r => (r.exitCode !== 0 ? { kind: 'exit', code: r.exitCode } : null) }
69
80
  ];
70
81
  /**
71
82
  * Classify a finished child. Returns `undefined` when nothing killed it —
@@ -74,7 +85,7 @@ const FAILURE_RULES = [
74
85
  */
75
86
  export function classifyWorkerFailure(r) {
76
87
  for (const rule of FAILURE_RULES) {
77
- const hit = rule(r);
88
+ const hit = rule.match(r);
78
89
  if (hit)
79
90
  return hit;
80
91
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The ROSTER of ways a worker child can die, and what each one implies.
3
+ *
4
+ * WHY IT EXISTS. One kill cause was named in six unlinked places: a
5
+ * `RunWorkerInput` guard option, a `RunWorkerResult` field, the
6
+ * `WorkerRestartReason` union, a `RESTART_RULES` row, the `CARRY_FORWARD_REASONS`
7
+ * set, and a `FAILURE_RULES` row. Adding one meant six coordinated edits, and
8
+ * only two of them failed to compile if you skipped one. That is not
9
+ * hypothetical: `worker-failure.ts`'s own header records the bug it cost —
10
+ * *"`streamStalled` was added to the result and to `finalAttemptFailed`, but the
11
+ * enforce ladder never grew an arm for it, so an enforcement child killed for a
12
+ * hung model stream fell all the way through to `if (aborted) return
13
+ * USER_CANCELLED`"*. That fix closed the READER side. This closes the author
14
+ * side.
15
+ *
16
+ * WHAT IS AND IS NOT UNIFIED. The roster is one table. The two ORDERINGS stay
17
+ * two, because they genuinely disagree and each says so in its own prose: the
18
+ * restart ladder puts `loop` first (its hint is the most specific thing to tell a
19
+ * re-spawn), and the failure ladder puts `stalled` first (the diagnosis most
20
+ * easily lost behind the `aborted` every kill path sets). Folding two
21
+ * precedences into one row type would need an escape hatch per row — the same
22
+ * objection that got a `WriteGuard` row table rejected. What the orderings gain
23
+ * here is that neither can name a cause with no row, nor silently omit one.
24
+ *
25
+ * Not every cause appears in both ladders, and that asymmetry is real:
26
+ * `connection-error` is restartable but is reported as a `modelError`, never as a
27
+ * kill; `stalled`, `aborted` and `exit` end an attempt outright and no hint would
28
+ * help.
29
+ */
30
+ /** Every way a worker attempt can end other than by answering. */
31
+ export type WorkerKillId = 'stalled' | 'command-timeout' | 'stream-stall' | 'worker-timeout' | 'connection-error' | 'loop' | 'leaked-tool-call' | 'aborted' | 'exit';
32
+ export interface WorkerKill {
33
+ id: WorkerKillId;
34
+ /**
35
+ * The `RunWorkerResult` field that reports this cause on the FINAL attempt,
36
+ * or null when the cause never reaches the result under its own name
37
+ * (`connection-error` arrives as `modelError`; `aborted` and `exit` are the
38
+ * generic fields every kill path also sets).
39
+ *
40
+ * A string rather than `keyof RunWorkerResult` so this module stays free of
41
+ * pi-worker-core's import graph; `worker-kill.test.ts` checks it against the
42
+ * real interface.
43
+ */
44
+ resultField: string | null;
45
+ /**
46
+ * Is a killed attempt's partial output worth carrying into the next one?
47
+ *
48
+ * A clock kill, a hung tool, an idle stream and a dropped socket all discard
49
+ * work the model genuinely did. A loop kill and a leaked tool call do not —
50
+ * the first is by definition the same call repeated, the second is malformed
51
+ * protocol text, and replaying either would feed the failure back to itself.
52
+ */
53
+ carryForward: boolean;
54
+ /** Does the restart ladder have a rule for this cause? */
55
+ restartable: boolean;
56
+ /** Does this cause reach a consumer as a `WorkerFailure`? */
57
+ reported: boolean;
58
+ }
59
+ export declare const WORKER_KILLS: readonly WorkerKill[];
60
+ /** Look one cause up. `undefined` only for an id with no row, which the suite forbids. */
61
+ export declare function workerKill(id: WorkerKillId): WorkerKill | undefined;
62
+ /**
63
+ * The restart ladder's precedence, as ids. `RESTART_RULES` must be exactly this,
64
+ * in this order.
65
+ *
66
+ * `loop` leads: its hint names the offending call, which is the most useful thing
67
+ * to tell a re-spawn. The two watchdogs come before the wall clock because each
68
+ * is the narrower diagnosis, and they cannot be confused with it — a watchdog
69
+ * kill leaves the worker's own timeout flag false.
70
+ */
71
+ export declare const RESTART_ORDER: readonly ["loop", "command-timeout", "stream-stall", "worker-timeout", "connection-error", "leaked-tool-call"];
72
+ /**
73
+ * The failure ladder's precedence, as ids. `FAILURE_RULES` must be exactly this,
74
+ * in this order.
75
+ *
76
+ * DIFFERENT from `RESTART_ORDER`, deliberately. Every kill path also sets
77
+ * `aborted` and a non-zero exit, so the specific causes must all be matched
78
+ * before the two generic ones or a dead backend is reported as "you cancelled".
79
+ * `stalled` leads because it is both the most specific diagnosis and the one most
80
+ * easily lost.
81
+ */
82
+ export declare const FAILURE_ORDER: readonly ["stalled", "command-timeout", "stream-stall", "worker-timeout", "loop", "leaked-tool-call", "aborted", "exit"];
83
+ /** The causes whose partial output is worth keeping. Derived, never hand-kept. */
84
+ export declare const CARRY_FORWARD_IDS: ReadonlySet<WorkerKillId>;