@mjasnikovs/pi-task 0.39.0 → 0.39.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/group-args.d.ts +24 -9
- package/dist/config/group-args.js +38 -28
- package/dist/config/register.d.ts +0 -9
- package/dist/config/register.js +17 -62
- package/dist/shared/child-process.d.ts +34 -32
- package/dist/shared/child-process.js +44 -58
- package/dist/shared/command-watchdog.d.ts +12 -4
- package/dist/shared/command-watchdog.js +6 -7
- package/dist/shared/connection-error.d.ts +7 -0
- package/dist/shared/connection-error.js +65 -0
- package/dist/shared/model-endpoint.d.ts +12 -24
- package/dist/shared/model-endpoint.js +32 -82
- package/dist/shared/model-resolve.d.ts +105 -0
- package/dist/shared/model-resolve.js +97 -0
- package/dist/shared/reasoning-capability.d.ts +20 -0
- package/dist/shared/reasoning-capability.js +32 -1
- package/dist/shared/stall-probe.d.ts +51 -0
- package/dist/shared/stall-probe.js +79 -0
- package/dist/task/child-runner.d.ts +76 -278
- package/dist/task/child-runner.js +186 -722
- package/dist/task/context-usage.js +2 -7
- package/dist/task/failure-classifier.js +53 -81
- package/dist/task/gate-child.js +1 -1
- package/dist/task/impl-widget.d.ts +2 -0
- package/dist/task/impl-widget.js +4 -0
- package/dist/task/implementation-hold.d.ts +11 -0
- package/dist/task/implementation-hold.js +20 -0
- package/dist/task/implementation-scope.d.ts +24 -0
- package/dist/task/implementation-scope.js +34 -0
- package/dist/task/loop-detector.d.ts +13 -5
- package/dist/task/loop-detector.js +11 -5
- package/dist/task/model-hold-stash.js +4 -14
- package/dist/task/orchestrator.d.ts +1 -8
- package/dist/task/orchestrator.js +11 -34
- package/dist/task/phases.js +2 -2
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +1 -1
- package/dist/workers/model-warning.d.ts +4 -16
- package/dist/workers/model-warning.js +14 -70
- package/dist/workers/pi-worker-core.d.ts +65 -20
- package/dist/workers/pi-worker-core.js +109 -50
- package/dist/workers/reasoning-warning.js +2 -24
- package/dist/workers/worker-failure.d.ts +2 -0
- package/dist/workers/worker-failure.js +2 -1
- package/dist/workers/worker-kill.d.ts +30 -11
- package/dist/workers/worker-kill.js +68 -20
- package/dist/workers/worker-profiles.d.ts +20 -0
- package/dist/workers/worker-profiles.js +22 -9
- package/package.json +1 -1
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The live-config bridge for per-group child settings: group in, argv fragment
|
|
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
|
-
*
|
|
10
|
-
* the next child without a restart. Same contract
|
|
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
|
-
|
|
15
|
-
export declare function
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
-
*
|
|
10
|
-
* the next child without a restart. Same contract
|
|
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
|
-
*
|
|
20
|
+
* What this session resolved each group's model cell to.
|
|
17
21
|
*
|
|
18
|
-
* WHY A SESSION-SCOPED
|
|
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
|
|
36
|
-
export function
|
|
37
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
86
|
-
*
|
|
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
|
*
|
package/dist/config/register.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getKeybindings, SettingsList, visibleWidth, wrapTextWithAnsi } from '@earendil-works/pi-tui';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
847
|
-
note: spec =>
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
|
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
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
clearInterval(stallTimer);
|
|
381
|
+
stallProbe?.stop();
|
|
399
382
|
streamWatch?.stop();
|
|
400
|
-
signal?.removeEventListener('abort',
|
|
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({
|
|
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
|
-
|
|
425
|
+
onAbort();
|
|
440
426
|
else
|
|
441
|
-
signal.addEventListener('abort',
|
|
427
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
442
428
|
}
|
|
443
429
|
});
|
|
444
430
|
}
|
|
@@ -130,6 +130,15 @@ export interface CommandKill {
|
|
|
130
130
|
* so the fresh child knows which call it must not repeat unbounded. */
|
|
131
131
|
detail?: string;
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The kill, as the REASON the watchdog aborts its signal with. runChild reads
|
|
135
|
+
* it off the combined signal, so a command kill reaches `ChildResult.kill` by
|
|
136
|
+
* the same road every other guard's kill does — not through a side query the
|
|
137
|
+
* caller has to remember to make.
|
|
138
|
+
*/
|
|
139
|
+
export interface CommandKillReason extends CommandKill {
|
|
140
|
+
by: 'command-timeout';
|
|
141
|
+
}
|
|
133
142
|
/**
|
|
134
143
|
* The tool-call fields the child-side watchdog reads. Structural rather than
|
|
135
144
|
* `ToolCall` from child-process.ts, so this module keeps its zero imports and a
|
|
@@ -142,9 +151,9 @@ export interface WatchedToolCall {
|
|
|
142
151
|
}
|
|
143
152
|
/**
|
|
144
153
|
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
145
|
-
* machine whose `onFire` aborts `signal
|
|
146
|
-
* process-GROUP kill — reaping the hung command itself,
|
|
147
|
-
* holding it.
|
|
154
|
+
* machine whose `onFire` aborts `signal` WITH the kill as its reason, which
|
|
155
|
+
* runChild turns into a process-GROUP kill — reaping the hung command itself,
|
|
156
|
+
* not just the pi child holding it — and reports as `kill.by === 'command-timeout'`.
|
|
148
157
|
*
|
|
149
158
|
* LIMIT: the group kill only reaches processes still IN the group. A hung command
|
|
150
159
|
* that detached a daemon (setsid, nohup, a background dev server) leaves it
|
|
@@ -158,7 +167,6 @@ export interface WatchedToolCall {
|
|
|
158
167
|
export declare function commandWatch(timeoutMs: number): {
|
|
159
168
|
onStart: (call: WatchedToolCall) => void;
|
|
160
169
|
onEnd: (toolCallId: string | undefined) => void;
|
|
161
|
-
killed: () => CommandKill | undefined;
|
|
162
170
|
signal: AbortSignal;
|
|
163
171
|
clear: () => void;
|
|
164
172
|
} | null;
|