@mjasnikovs/pi-task 0.39.0 → 0.39.2

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 (50) hide show
  1. package/README.md +2 -2
  2. package/dist/config/group-args.d.ts +24 -9
  3. package/dist/config/group-args.js +38 -28
  4. package/dist/config/register.d.ts +0 -9
  5. package/dist/config/register.js +17 -62
  6. package/dist/shared/child-process.d.ts +34 -32
  7. package/dist/shared/child-process.js +44 -58
  8. package/dist/shared/command-watchdog.d.ts +12 -4
  9. package/dist/shared/command-watchdog.js +6 -7
  10. package/dist/shared/connection-error.d.ts +7 -0
  11. package/dist/shared/connection-error.js +65 -0
  12. package/dist/shared/model-endpoint.d.ts +12 -24
  13. package/dist/shared/model-endpoint.js +32 -82
  14. package/dist/shared/model-resolve.d.ts +105 -0
  15. package/dist/shared/model-resolve.js +97 -0
  16. package/dist/shared/reasoning-capability.d.ts +20 -0
  17. package/dist/shared/reasoning-capability.js +32 -1
  18. package/dist/shared/stall-probe.d.ts +51 -0
  19. package/dist/shared/stall-probe.js +79 -0
  20. package/dist/task/child-runner.d.ts +76 -278
  21. package/dist/task/child-runner.js +186 -722
  22. package/dist/task/context-usage.js +2 -7
  23. package/dist/task/failure-classifier.js +53 -81
  24. package/dist/task/gate-child.js +1 -1
  25. package/dist/task/impl-widget.d.ts +2 -0
  26. package/dist/task/impl-widget.js +4 -0
  27. package/dist/task/implementation-hold.d.ts +11 -0
  28. package/dist/task/implementation-hold.js +20 -0
  29. package/dist/task/implementation-scope.d.ts +24 -0
  30. package/dist/task/implementation-scope.js +34 -0
  31. package/dist/task/loop-detector.d.ts +13 -5
  32. package/dist/task/loop-detector.js +11 -5
  33. package/dist/task/model-hold-stash.js +4 -14
  34. package/dist/task/orchestrator.d.ts +1 -8
  35. package/dist/task/orchestrator.js +11 -34
  36. package/dist/task/phases.js +2 -2
  37. package/dist/task/stall-detector.d.ts +1 -1
  38. package/dist/task/stall-detector.js +1 -1
  39. package/dist/workers/model-warning.d.ts +4 -16
  40. package/dist/workers/model-warning.js +14 -70
  41. package/dist/workers/pi-worker-core.d.ts +65 -20
  42. package/dist/workers/pi-worker-core.js +109 -50
  43. package/dist/workers/reasoning-warning.js +2 -24
  44. package/dist/workers/worker-failure.d.ts +2 -0
  45. package/dist/workers/worker-failure.js +2 -1
  46. package/dist/workers/worker-kill.d.ts +30 -11
  47. package/dist/workers/worker-kill.js +68 -20
  48. package/dist/workers/worker-profiles.d.ts +20 -0
  49. package/dist/workers/worker-profiles.js +22 -9
  50. package/package.json +1 -1
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![npm](https://img.shields.io/npm/v/@mjasnikovs/pi-task?color=cb3837&logo=npm)](https://www.npmjs.com/package/@mjasnikovs/pi-task)
10
10
  [![license](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](./LICENSE)
11
11
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)
12
- [![tests](https://img.shields.io/badge/tests-4320%20passing-3fb950)](#development)
12
+ [![tests](https://img.shields.io/badge/tests-4118%20passing-3fb950)](#development)
13
13
  [![types](https://img.shields.io/badge/TypeScript-strict-3178c6?logo=typescript&logoColor=white)](./tsconfig.json)
14
14
 
15
15
  </div>
@@ -244,7 +244,7 @@ them checked in.
244
244
 
245
245
  ```sh
246
246
  bun install
247
- bun run test # 4321 tests across 242 files
247
+ bun run test # 4119 tests across 227 files
248
248
  bun run lint # prettier + eslint + tsc --noEmit
249
249
  bun run build # tsc → dist/
250
250
  ```
@@ -1,21 +1,33 @@
1
1
  /**
2
- * The live-config bridge for per-group child settings: group in, argv fragment out.
2
+ * The live-config bridge for per-group child settings: group in, argv fragment
3
+ * out — plus the session's model snapshot, which the argv needs and which only a
4
+ * session_start with a `ctx` can produce.
3
5
  *
4
6
  * Separate from reasoning.ts and group-models.ts because those must take no
5
7
  * import with a runtime side effect — see their headers. The `getConfig()` read
6
8
  * lives here instead: this file imports them and nothing in config/ imports it
7
9
  * back, so the graph stays a tree.
8
10
  *
9
- * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
- * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ * Config is read PER CALL, never cached at module scope, so a /task-config
12
+ * change lands on the next child without a restart. Same contract
13
+ * `childBaseArgs` keeps. The snapshot is the one deliberate exception, and it is
14
+ * a snapshot of the REGISTRY, not of config.
11
15
  */
12
16
  import { type PiTaskConfig } from './config.js';
13
17
  import type { ChildGroup } from './groups.js';
14
- export declare function setUnusableSpecs(specs: Iterable<string>): void;
15
- export declare function isSpecUsable(spec: string): boolean;
16
- export declare function setGroupWindows(windows: Readonly<Partial<Record<ChildGroup, number>>>): void;
17
- /** The group's own window, or `undefined` for "caller keeps its fallback". */
18
+ import type { GroupModelSnapshot } from '../shared/model-resolve.js';
19
+ export declare function setGroupModels(snapshot: Readonly<Partial<Record<ChildGroup, GroupModelSnapshot>>>): void;
20
+ /**
21
+ * The group's own window, or `undefined` for "caller keeps its fallback".
22
+ *
23
+ * The number drives `StallDetector`'s churn rule, where the two error directions
24
+ * are NOT symmetric: too large fires late (degraded, and the no-new-ground rule
25
+ * still covers it), too small fires early and KILLS A HEALTHY CHILD. So an
26
+ * absent answer means "use the parent's", never a guess.
27
+ */
18
28
  export declare function groupWindow(group: ChildGroup): number | undefined;
29
+ export declare function setModelEndpoints(endpoints: ReadonlyMap<string, string>): void;
30
+ export declare function modelEndpoint(spec: string): string | undefined;
19
31
  /**
20
32
  * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
21
33
  * spec this session proved unresolvable.
@@ -26,14 +38,17 @@ export declare function groupWindow(group: ChildGroup): number | undefined;
26
38
  * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
27
39
  * at exit 0. Dropping the flag runs the same child the user got last week and
28
40
  * says so out loud; passing it runs a model nobody chose and says nothing.
41
+ *
42
+ * The verdict applies to the spec it was proven on: a cell changed since
43
+ * session_start is emitted, and pi decides.
29
44
  */
30
45
  export declare function groupModelArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
31
46
  /**
32
47
  * The `['--thinking', level]` fragment for a group, or `[]` when the group is
33
48
  * `inherit` and the child should keep falling back to settings.json.
34
49
  *
35
- * Still exported on its own: the host-session turn (implementation-hold.ts) and
36
- * the settings UI (register.ts) need the level rather than a whole fragment.
50
+ * Exported for its own tests only: the per-call config read is a contract on
51
+ * each half, and only the half on its own can assert it.
37
52
  */
38
53
  export declare function groupThinkingArgs(group: ChildGroup, cfg?: PiTaskConfig): string[];
39
54
  /**
@@ -1,22 +1,26 @@
1
1
  /**
2
- * The live-config bridge for per-group child settings: group in, argv fragment out.
2
+ * The live-config bridge for per-group child settings: group in, argv fragment
3
+ * out — plus the session's model snapshot, which the argv needs and which only a
4
+ * session_start with a `ctx` can produce.
3
5
  *
4
6
  * Separate from reasoning.ts and group-models.ts because those must take no
5
7
  * import with a runtime side effect — see their headers. The `getConfig()` read
6
8
  * lives here instead: this file imports them and nothing in config/ imports it
7
9
  * back, so the graph stays a tree.
8
10
  *
9
- * Read PER CALL, never cached at module scope, so a /task-config change lands on
10
- * the next child without a restart. Same contract `childBaseArgs` keeps.
11
+ * Config is read PER CALL, never cached at module scope, so a /task-config
12
+ * change lands on the next child without a restart. Same contract
13
+ * `childBaseArgs` keeps. The snapshot is the one deliberate exception, and it is
14
+ * a snapshot of the REGISTRY, not of config.
11
15
  */
12
16
  import { getConfig } from './config.js';
13
17
  import { MODEL_INHERIT, modelArgs } from './group-models.js';
14
18
  import { resolveReasoning, thinkingArgs } from './reasoning.js';
15
19
  /**
16
- * Specs this session has proven a child cannot resolve.
20
+ * What this session resolved each group's model cell to.
17
21
  *
18
- * WHY A SESSION-SCOPED SET AND NOT A LOOKUP
19
- * -----------------------------------------
22
+ * WHY A SESSION-SCOPED SNAPSHOT AND NOT A LOOKUP
23
+ * ----------------------------------------------
20
24
  * The honest question is "can a `--no-extensions` child resolve this spec?", and
21
25
  * only `ctx.modelRegistry` can answer it. Five of the six argv producers have no
22
26
  * `ctx` — `pi-worker`, `pi-worker-docs`, `docs-core`, `fetch-core` and
@@ -26,41 +30,43 @@ import { resolveReasoning, thinkingArgs } from './reasoning.js';
26
30
  * this project does not depend on pi-ai.
27
31
  *
28
32
  * So it is answered ONCE, at session_start, where ctx exists and every task is
29
- * still in the future, and the verdict is left here.
33
+ * still in the future, and the whole verdict is left here — usability and the
34
+ * context window from ONE walk, so the argv and the churn rule can never
35
+ * disagree about which model a group runs on.
30
36
  *
31
37
  * EMPTY MEANS EMIT. A host that never fires session_start therefore behaves
32
38
  * exactly as it does today — the failure direction is "pi decides", never "we
33
39
  * silently dropped a flag nobody checked".
34
40
  */
35
- let unusableSpecs = new Set();
36
- export function setUnusableSpecs(specs) {
37
- unusableSpecs = new Set(specs);
38
- }
39
- export function isSpecUsable(spec) {
40
- return !unusableSpecs.has(spec);
41
+ let groupModels = {};
42
+ export function setGroupModels(snapshot) {
43
+ groupModels = { ...snapshot };
41
44
  }
42
45
  /**
43
- * The context window of each group's model, resolved in the SAME session pass
44
- * that filled {@link setUnusableSpecs}.
45
- *
46
- * It lives here for the same reason that set does — `child-runner` and the
47
- * workers have no `ctx`, so they cannot ask a registry — and it is filled by the
48
- * same walk, so the two can never disagree about which model a group runs on.
46
+ * The group's own window, or `undefined` for "caller keeps its fallback".
49
47
  *
50
48
  * The number drives `StallDetector`'s churn rule, where the two error directions
51
49
  * are NOT symmetric: too large fires late (degraded, and the no-new-ground rule
52
50
  * still covers it), too small fires early and KILLS A HEALTHY CHILD. So an
53
51
  * absent answer means "use the parent's", never a guess.
54
52
  */
55
- let groupWindows = {};
56
- export function setGroupWindows(windows) {
57
- groupWindows = { ...windows };
58
- }
59
- /** The group's own window, or `undefined` for "caller keeps its fallback". */
60
53
  export function groupWindow(group) {
61
- const w = groupWindows[group];
54
+ const w = groupModels[group]?.contextWindow;
62
55
  return w !== undefined && w > 0 ? w : undefined;
63
56
  }
57
+ /**
58
+ * `spec → baseUrl` for every model the session can use, from the same
59
+ * session_start pass. Read by the dead-backend probe (shared/model-endpoint.ts),
60
+ * which runs where no `ctx` exists. Empty until a session starts, and the probe
61
+ * reads "no url" as "cannot see this server, so never kill".
62
+ */
63
+ let modelEndpoints = new Map();
64
+ export function setModelEndpoints(endpoints) {
65
+ modelEndpoints = new Map(endpoints);
66
+ }
67
+ export function modelEndpoint(spec) {
68
+ return modelEndpoints.get(spec);
69
+ }
64
70
  /**
65
71
  * The `['--model', spec]` fragment for a group, or `[]` for `inherit` and for a
66
72
  * spec this session proved unresolvable.
@@ -71,19 +77,23 @@ export function groupWindow(group) {
71
77
  * `reasoning: true` onto it, inherits the provider's default baseUrl and answers
72
78
  * at exit 0. Dropping the flag runs the same child the user got last week and
73
79
  * says so out loud; passing it runs a model nobody chose and says nothing.
80
+ *
81
+ * The verdict applies to the spec it was proven on: a cell changed since
82
+ * session_start is emitted, and pi decides.
74
83
  */
75
84
  export function groupModelArgs(group, cfg) {
76
85
  const spec = (cfg ?? getConfig()).groupModels[group];
77
86
  if (spec === undefined || spec === MODEL_INHERIT)
78
87
  return [];
79
- return isSpecUsable(spec) ? modelArgs(spec) : [];
88
+ const proven = groupModels[group];
89
+ return proven?.spec === spec && !proven.usable ? [] : modelArgs(spec);
80
90
  }
81
91
  /**
82
92
  * The `['--thinking', level]` fragment for a group, or `[]` when the group is
83
93
  * `inherit` and the child should keep falling back to settings.json.
84
94
  *
85
- * Still exported on its own: the host-session turn (implementation-hold.ts) and
86
- * the settings UI (register.ts) need the level rather than a whole fragment.
95
+ * Exported for its own tests only: the per-call config read is a contract on
96
+ * each half, and only the half on its own can assert it.
87
97
  */
88
98
  export function groupThinkingArgs(group, cfg) {
89
99
  // The default is evaluated HERE, per call. Hoisting the read to module scope
@@ -178,15 +178,6 @@ export interface ModelCatalog {
178
178
  }
179
179
  /** No registry reachable. Every row still renders; nothing narrows. */
180
180
  export declare const EMPTY_CATALOG: ModelCatalog;
181
- /**
182
- * The levels a row may offer, given the model that row's group will run on.
183
- *
184
- * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
185
- * directly: that returns the whole ladder including `xhigh` and `max`, which
186
- * this menu excludes on purpose (see reasoning.ts) because pi's own UI may not
187
- * offer them. A model declaring `xhigh` must not smuggle it in here.
188
- */
189
- export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
190
181
  /**
191
182
  * `level · provider/id`, the one string a step row shows and accepts.
192
183
  *
@@ -1,6 +1,7 @@
1
1
  import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
2
- import { clampToModel, supportedThinkingLevels } from '../shared/reasoning-capability.js';
3
- import { MODEL_INHERIT, splitSpec } from './group-models.js';
2
+ import { effectiveSetting, offeredLevels } from '../shared/reasoning-capability.js';
3
+ import { resolveModel, specOf } from '../shared/model-resolve.js';
4
+ import { MODEL_INHERIT } from './group-models.js';
4
5
  import { PairPicker } from './option-picker.js';
5
6
  import { registerBridgeCommand } from '../remote/bridge.js';
6
7
  import { readPkgVersion } from '../shared/pkg-version.js';
@@ -382,20 +383,6 @@ export function stepRowLabel(group) {
382
383
  }
383
384
  /** No registry reachable. Every row still renders; nothing narrows. */
384
385
  export const EMPTY_CATALOG = { specs: [], facts: () => undefined };
385
- /**
386
- * The levels a row may offer, given the model that row's group will run on.
387
- *
388
- * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
389
- * directly: that returns the whole ladder including `xhigh` and `max`, which
390
- * this menu excludes on purpose (see reasoning.ts) because pi's own UI may not
391
- * offer them. A model declaring `xhigh` must not smuggle it in here.
392
- */
393
- export function offeredLevels(facts) {
394
- if (facts === undefined)
395
- return [...REASONING_SETTINGS];
396
- const supported = supportedThinkingLevels(facts);
397
- return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
398
- }
399
386
  /** The separator between a step row's two halves. */
400
387
  const PAIR_SEP = ' \u00b7 ';
401
388
  /**
@@ -498,19 +485,10 @@ function stepPicker(group, cfg, catalog) {
498
485
  ],
499
486
  second: spec => {
500
487
  const facts = catalog.facts(spec);
501
- const offered = offeredLevels(facts);
502
488
  const wanted = resolveReasoning(group, cfg);
503
- const clamped = facts === undefined || wanted === 'inherit' ?
504
- wanted
505
- : clampToModel(facts, wanted);
506
- // Back inside the menu's own vocabulary. `clampToModel` walks UP
507
- // first and knows the whole ladder, so a model declaring `xhigh`
508
- // can land on a level `offeredLevels` deliberately excludes — and
509
- // then stage two would open on `inherit` with the explanation
510
- // attached to no row at all.
511
- const runs = offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
489
+ const runs = effectiveSetting(facts, wanted);
512
490
  return {
513
- options: offered.map(level => ({
491
+ options: offeredLevels(facts).map(level => ({
514
492
  value: level,
515
493
  label: level,
516
494
  ...(level === runs && runs !== wanted ?
@@ -551,10 +529,7 @@ export function applyStepValue(cfg, group, chosen, catalog) {
551
529
  return;
552
530
  }
553
531
  cfg.groupModels = { ...cfg.groupModels, [group]: pair.spec };
554
- const facts = catalog.facts(pair.spec);
555
- const level = facts === undefined || pair.level === 'inherit' ?
556
- pair.level
557
- : clampToModel(facts, pair.level);
532
+ const level = effectiveSetting(catalog.facts(pair.spec), pair.level);
558
533
  // Only when it MOVES something: `applyReasoningLevel` flips the whole table
559
534
  // to `custom`, and picking a pair the config already runs must not do that
560
535
  // as a side effect.
@@ -831,47 +806,27 @@ function liveCatalog(ctx) {
831
806
  // registry that cannot answer must cost the model rows, never the menu.
832
807
  // Every row below still renders; `EMPTY_CATALOG` offers only `inherit` and
833
808
  // narrows nothing, which is exactly the pre-feature panel.
834
- let registry;
835
809
  let available;
836
- let fromExtension;
837
810
  try {
838
- registry = ctx.modelRegistry;
839
- available = registry.getAvailable();
840
- fromExtension = new Set(registry.getRegisteredProviderIds());
811
+ available = ctx.modelRegistry.getAvailable();
841
812
  }
842
813
  catch {
843
814
  return EMPTY_CATALOG;
844
815
  }
845
816
  return {
846
- specs: available.map(m => `${m.provider}/${m.id}`),
847
- note: spec => {
848
- const parts = splitSpec(spec);
849
- return parts && fromExtension.has(parts.provider) ?
850
- 'provider comes from an extension — whitelist it under child extensions, '
851
- + "or this group's children exit 1"
852
- : undefined;
853
- },
854
- facts: spec => {
855
- // `inherit` means the session's own model, which is what a child
856
- // resolves today. Its facts are what the thinking row must narrow to.
857
- if (spec === MODEL_INHERIT)
858
- return ctx.model;
859
- const parts = splitSpec(spec);
860
- return parts ? registry.find(parts.provider, parts.id) : undefined;
861
- }
817
+ specs: available.map(specOf),
818
+ note: spec => resolveModel(ctx, spec)?.fromExtension ?
819
+ 'provider comes from an extension — whitelist it under child extensions, '
820
+ + "or this group's children exit 1"
821
+ : undefined,
822
+ facts: spec => resolveModel(ctx, spec)
862
823
  };
863
824
  }
864
825
  async function handleTaskConfig(_args, ctx, getTools = () => []) {
865
- const cfg = {
866
- ...getConfig(),
867
- extensionWhitelist: [...getConfig().extensionWhitelist],
868
- commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools],
869
- // Copied for the same reason as the two arrays above: the panel mutates
870
- // its own draft, and sharing the live object would apply half-made
871
- // choices to running children before the user finished choosing.
872
- reasoningLevels: { ...getConfig().reasoningLevels },
873
- groupModels: { ...getConfig().groupModels }
874
- };
826
+ // A deep copy: the panel mutates its own draft, and sharing the live object
827
+ // would apply half-made choices to running children before the user
828
+ // finished choosing.
829
+ const cfg = structuredClone(getConfig());
875
830
  // Enumerated live at open so an installed extension appears and an
876
831
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
877
832
  // enumeration only costs the extension toggles, never the whole menu.
@@ -1,4 +1,6 @@
1
1
  import type { EventEmitter } from 'node:events';
2
+ import type { CommandKillReason } from './command-watchdog.js';
3
+ import type { LoopHit } from '../task/loop-detector.js';
2
4
  /** Grace period between SIGTERM and SIGKILL (ms). */
3
5
  export declare const KILL_GRACE_MS = 5000;
4
6
  /** Base flags shared by all child pi invocations. */
@@ -36,11 +38,41 @@ export type SpawnFn = (command: string, args: ReadonlyArray<string>, options: {
36
38
  * model children (json-events); plumbing stays in-group. */
37
39
  detached?: boolean;
38
40
  }) => ProcLike;
41
+ /**
42
+ * Why runChild killed the child. Five sources converge on one kill path, and
43
+ * each names itself here rather than in its own flag — so a consumer reads ONE
44
+ * field, and a sixth source is one more member, not a fourth boolean that every
45
+ * consumer has to remember to test before `aborted`.
46
+ *
47
+ * `aborted` is the caller's signal with no cause attached: a user cancel, or a
48
+ * wall clock that aborts without saying so. `command-timeout` also arrives
49
+ * through the signal, but the watchdog aborts WITH its kill as the reason
50
+ * (command-watchdog.ts), which is how it stays a member here instead of an
51
+ * out-of-band query.
52
+ */
53
+ export type ChildKill = {
54
+ by: 'aborted';
55
+ } | {
56
+ by: 'loop';
57
+ hit: LoopHit;
58
+ } | {
59
+ by: 'stream-stall';
60
+ idleMs: number;
61
+ } | {
62
+ by: 'stalled';
63
+ } | CommandKillReason;
39
64
  export interface ChildResult {
40
65
  stdout: string;
41
66
  stderr: string;
42
67
  exitCode: number;
68
+ /** true exactly when `kill` is set. Kept as a flag for the text-mode callers. */
43
69
  aborted: boolean;
70
+ /**
71
+ * Set when WE ended the child. Its exit status then describes our SIGTERM
72
+ * and says nothing about the child's verdict, so a consumer must read this
73
+ * before the exit code.
74
+ */
75
+ kill?: ChildKill;
44
76
  /** Extracted assistant text (only populated in json-events mode). */
45
77
  text?: string;
46
78
  /**
@@ -51,24 +83,6 @@ export interface ChildResult {
51
83
  * Only populated in json-events mode.
52
84
  */
53
85
  modelError?: string;
54
- /**
55
- * true when the stall guard killed the child: no output for the stall
56
- * window AND the model endpoint probe found the backend unreachable.
57
- * Callers must check this BEFORE `aborted` — the kill sets aborted too,
58
- * and without the flag it would mislabel as a user cancel.
59
- */
60
- stalled?: boolean;
61
- /**
62
- * true when the STREAM watchdog killed the child: no output at all for the
63
- * configured inactivity window, regardless of whether the backend answers a
64
- * probe. Distinct from `stalled`, which requires an UNREACHABLE endpoint —
65
- * these hangs have a perfectly healthy server and a dead stream, so the
66
- * probe path could never fire. Callers must check this BEFORE `aborted`
67
- * (the kill sets aborted too) and route it into the connection-error retry.
68
- */
69
- streamStalled?: {
70
- idleMs: number;
71
- };
72
86
  }
73
87
  export interface ToolCall {
74
88
  name: string;
@@ -83,19 +97,7 @@ export interface ToolCall {
83
97
  */
84
98
  toolCallId?: string;
85
99
  }
86
- export interface LoopHit {
87
- call: ToolCall;
88
- count: number;
89
- windowSize: number;
90
- /**
91
- * Set when the kill came from the whole-run StallDetector rather than the
92
- * short-window LoopDetector, naming which of its two rules tripped
93
- * (task/stall-detector.ts). Absent for an ordinary loop hit. Carried here so
94
- * a stall rides the kill/restart plumbing the loop hit already has instead of
95
- * needing a second channel.
96
- */
97
- stall?: 'no-new-ground' | 'context-churn';
98
- }
100
+ export type { LoopHit } from '../task/loop-detector.js';
99
101
  export interface ContextSnapshot {
100
102
  tokens: number;
101
103
  contextWindow: number;
@@ -215,7 +217,7 @@ export declare class JsonEventSink {
215
217
  private buf;
216
218
  constructor(opts: RunChildJsonEventsOptions,
217
219
  /** Invoked when onToolCall reports a loop hit — runChild kills the child. */
218
- onLoopKill: () => void);
220
+ onLoopKill: (hit: LoopHit) => void);
219
221
  /** Feed a raw stdout chunk: parse every complete line, buffer the partial tail. */
220
222
  feed(chunk: string): void;
221
223
  /** Flush a trailing event that wasn't newline-terminated (call on close). */
@@ -1,5 +1,6 @@
1
1
  import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
2
2
  import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
3
+ import { realStallTimerDeps, StallProbe } from './stall-probe.js';
3
4
  import { workerChannel } from '../workers/worker-channels.js';
4
5
  /** Grace period between SIGTERM and SIGKILL (ms). */
5
6
  export const KILL_GRACE_MS = 5000;
@@ -12,6 +13,11 @@ export const CHILD_BASE_ARGS = [
12
13
  '--no-context-files',
13
14
  '--no-session'
14
15
  ];
16
+ /** The cause a signal was aborted with, when its owner attached one. */
17
+ function abortCause(reason) {
18
+ const tagged = reason;
19
+ return tagged?.by === 'command-timeout' ? reason : { by: 'aborted' };
20
+ }
15
21
  // ─── JSON event-stream sink ──────────────────────────────────────────────────
16
22
  /**
17
23
  * Parses a child's `--mode json` event stream into assistant text plus side
@@ -179,7 +185,7 @@ export class JsonEventSink {
179
185
  if (opts.onToolCall) {
180
186
  const hit = opts.onToolCall({ name: tn, args: evt.args, toolCallId: id });
181
187
  if (hit)
182
- this.onLoopKill();
188
+ this.onLoopKill(hit);
183
189
  }
184
190
  return;
185
191
  }
@@ -208,7 +214,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
208
214
  return new Promise(resolve => {
209
215
  let stdout = '';
210
216
  let stderr = '';
211
- let aborted = false;
217
+ let kill;
212
218
  const discardStdout = opts?.mode === 'text' && opts.discardStdout === true;
213
219
  // Deliver the prompt on stdin, not argv. A large prompt — an inlined design
214
220
  // doc, say — exceeds the OS argv ceiling and the spawn fails outright rather
@@ -253,12 +259,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
253
259
  // group already gone
254
260
  }
255
261
  };
256
- // One kill path, shared by user-abort and loop-kill: SIGTERM, then SIGKILL
257
- // after a grace period if the child ignored the term. For a group-owning
258
- // (model) child, ALSO sweep the group so anything it backgrounded dies with
259
- // it — proc.kill hits only the leader, reapGroup the grandchildren.
260
- const killProc = () => {
261
- aborted = true;
262
+ // One kill path for every source: SIGTERM, then SIGKILL after a grace
263
+ // period if the child ignored the term. For a group-owning (model) child,
264
+ // ALSO sweep the group so anything it backgrounded dies with it —
265
+ // proc.kill hits only the leader, reapGroup the grandchildren. The FIRST
266
+ // cause wins: a stall kill's SIGTERM can trip the abort path behind it.
267
+ const killProc = (cause) => {
268
+ kill ??= cause;
262
269
  proc.kill('SIGTERM');
263
270
  if (ownGroup)
264
271
  reapGroup('SIGTERM');
@@ -274,15 +281,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
274
281
  // below structurally cannot catch. Suspended for the duration of a tool
275
282
  // call: a 12-minute build legitimately emits nothing, and that window is
276
283
  // the COMMAND watchdog's to police, not this one's.
277
- let streamStalledIdleMs;
278
284
  const streamWatch = opts?.mode === 'json-events' && (opts.streamInactivityMs ?? 0) > 0 ?
279
285
  new StreamWatchdog({
280
286
  getTimeoutMs: () => opts.streamInactivityMs,
281
287
  ...realStreamTimerDeps,
282
- onFire: idleMs => {
283
- streamStalledIdleMs = idleMs;
284
- killProc();
285
- }
288
+ onFire: idleMs => killProc({ by: 'stream-stall', idleMs })
286
289
  })
287
290
  : null;
288
291
  streamWatch?.start();
@@ -307,41 +310,21 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
307
310
  }
308
311
  }
309
312
  : opts;
310
- const sink = sinkOpts ? new JsonEventSink(sinkOpts, killProc) : null;
311
- // Dead-backend stall guard (json-events children only; see the option
312
- // docs). Any output resets the window; a reachable probe also resets it
313
- // so the next probe is a full window away, not every tick.
313
+ const sink = sinkOpts ? new JsonEventSink(sinkOpts, hit => killProc({ by: 'loop', hit })) : null;
314
+ // Dead-backend stall guard (json-events children only; see the option docs).
314
315
  const stall = opts?.mode === 'json-events' ? opts.stall : undefined;
315
- let lastActivity = Date.now();
316
- let stalled = false;
317
- let probing = false;
318
- const stallTimer = stall ?
319
- setInterval(() => {
320
- if (probing || Date.now() - lastActivity < stall.afterMs)
321
- return;
322
- probing = true;
323
- stall
324
- .probe()
325
- .then(reachable => {
326
- probing = false;
327
- if (reachable || stalled) {
328
- lastActivity = Date.now();
329
- return;
330
- }
331
- stalled = true;
332
- killProc();
333
- })
334
- .catch(() => {
335
- // A probe that itself crashed proves nothing —
336
- // benefit of the doubt, keep waiting.
337
- probing = false;
338
- lastActivity = Date.now();
339
- });
340
- }, Math.max(50, Math.min(stall.afterMs / 2, 15_000)))
341
- : undefined;
316
+ const stallProbe = stall ?
317
+ new StallProbe({
318
+ afterMs: stall.afterMs,
319
+ probe: stall.probe,
320
+ ...realStallTimerDeps,
321
+ onDead: () => killProc({ by: 'stalled' })
322
+ })
323
+ : null;
324
+ stallProbe?.start();
342
325
  let firstByteFired = false;
343
326
  proc.stdout?.on('data', (d) => {
344
- lastActivity = Date.now();
327
+ stallProbe?.note();
345
328
  streamWatch?.note();
346
329
  if (!firstByteFired) {
347
330
  firstByteFired = true;
@@ -378,10 +361,11 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
378
361
  // research-worker slices the TAIL of stderr in two places and the HEAD in a
379
362
  // third, and child-runner feeds the whole string into its failure message.
380
363
  proc.stderr?.on('data', (d) => {
381
- lastActivity = Date.now();
364
+ stallProbe?.note();
382
365
  streamWatch?.note();
383
366
  stderr += d.toString();
384
367
  });
368
+ const onAbort = () => killProc(abortCause(signal?.reason));
385
369
  // One idempotent settle path for close/error/abort. Detaching the abort
386
370
  // listener here is the point. `{once: true}` fires-and-removes on an ACTUAL
387
371
  // abort and at no other time, so a child that finishes normally leaves its
@@ -394,10 +378,9 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
394
378
  // the caller's callbacks close over.
395
379
  let settled = false;
396
380
  const cleanup = () => {
397
- if (stallTimer)
398
- clearInterval(stallTimer);
381
+ stallProbe?.stop();
399
382
  streamWatch?.stop();
400
- signal?.removeEventListener('abort', killProc);
383
+ signal?.removeEventListener('abort', onAbort);
401
384
  };
402
385
  const settle = (result) => {
403
386
  cleanup();
@@ -422,23 +405,26 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
422
405
  stdout,
423
406
  stderr,
424
407
  exitCode: code ?? 0,
425
- aborted,
408
+ aborted: kill !== undefined,
409
+ ...(kill ? { kill } : {}),
426
410
  text,
427
- modelError: sink?.modelError,
428
- ...(stalled ? { stalled: true } : {}),
429
- ...(streamStalledIdleMs !== undefined ?
430
- { streamStalled: { idleMs: streamStalledIdleMs } }
431
- : {})
411
+ modelError: sink?.modelError
432
412
  });
433
413
  });
434
414
  proc.once('error', () => {
435
- settle({ stdout, stderr, exitCode: 1, aborted });
415
+ settle({
416
+ stdout,
417
+ stderr,
418
+ exitCode: 1,
419
+ aborted: kill !== undefined,
420
+ ...(kill ? { kill } : {})
421
+ });
436
422
  });
437
423
  if (signal) {
438
424
  if (signal.aborted)
439
- killProc();
425
+ onAbort();
440
426
  else
441
- signal.addEventListener('abort', killProc, { once: true });
427
+ signal.addEventListener('abort', onAbort, { once: true });
442
428
  }
443
429
  });
444
430
  }