@mjasnikovs/pi-task 0.38.32 → 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.
Files changed (54) 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/option-picker.d.ts +39 -11
  5. package/dist/config/option-picker.js +52 -12
  6. package/dist/config/reasoning.d.ts +10 -7
  7. package/dist/config/reasoning.js +19 -32
  8. package/dist/config/register.d.ts +66 -41
  9. package/dist/config/register.js +201 -162
  10. package/dist/shared/child-process.d.ts +34 -32
  11. package/dist/shared/child-process.js +44 -58
  12. package/dist/shared/command-watchdog.d.ts +12 -4
  13. package/dist/shared/command-watchdog.js +6 -7
  14. package/dist/shared/connection-error.d.ts +7 -0
  15. package/dist/shared/connection-error.js +65 -0
  16. package/dist/shared/model-endpoint.d.ts +12 -24
  17. package/dist/shared/model-endpoint.js +32 -82
  18. package/dist/shared/model-resolve.d.ts +105 -0
  19. package/dist/shared/model-resolve.js +97 -0
  20. package/dist/shared/reasoning-capability.d.ts +20 -0
  21. package/dist/shared/reasoning-capability.js +32 -1
  22. package/dist/shared/stall-probe.d.ts +51 -0
  23. package/dist/shared/stall-probe.js +79 -0
  24. package/dist/task/child-runner.d.ts +76 -278
  25. package/dist/task/child-runner.js +186 -722
  26. package/dist/task/context-usage.js +2 -7
  27. package/dist/task/failure-classifier.js +53 -81
  28. package/dist/task/gate-child.js +1 -1
  29. package/dist/task/impl-widget.d.ts +2 -0
  30. package/dist/task/impl-widget.js +4 -0
  31. package/dist/task/implementation-hold.d.ts +11 -0
  32. package/dist/task/implementation-hold.js +20 -0
  33. package/dist/task/implementation-scope.d.ts +24 -0
  34. package/dist/task/implementation-scope.js +34 -0
  35. package/dist/task/loop-detector.d.ts +13 -5
  36. package/dist/task/loop-detector.js +11 -5
  37. package/dist/task/model-hold-stash.js +4 -14
  38. package/dist/task/orchestrator.d.ts +1 -8
  39. package/dist/task/orchestrator.js +11 -34
  40. package/dist/task/phases.js +2 -2
  41. package/dist/task/stall-detector.d.ts +1 -1
  42. package/dist/task/stall-detector.js +1 -1
  43. package/dist/workers/model-warning.d.ts +4 -16
  44. package/dist/workers/model-warning.js +14 -70
  45. package/dist/workers/pi-worker-core.d.ts +65 -20
  46. package/dist/workers/pi-worker-core.js +109 -50
  47. package/dist/workers/reasoning-warning.js +2 -24
  48. package/dist/workers/worker-failure.d.ts +2 -0
  49. package/dist/workers/worker-failure.js +2 -1
  50. package/dist/workers/worker-kill.d.ts +30 -11
  51. package/dist/workers/worker-kill.js +68 -20
  52. package/dist/workers/worker-profiles.d.ts +20 -0
  53. package/dist/workers/worker-profiles.js +22 -9
  54. package/package.json +1 -1
@@ -0,0 +1,97 @@
1
+ import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
2
+ import { CHILD_GROUPS } from '../config/groups.js';
3
+ /** The `provider/id` inverse of {@link splitSpec}. */
4
+ export function specOf(model) {
5
+ return `${model.provider}/${model.id}`;
6
+ }
7
+ /**
8
+ * `inherit` is the session's model. That is decision 3 of the model table —
9
+ * children are NOT switched to follow the host — and the honest value is
10
+ * settings.json's default, which need not be the session's. Naming the
11
+ * session's model is still the better of the two: it is the one the user can
12
+ * see, and on every machine with one provider the two agree.
13
+ *
14
+ * `find` is EXACT, deliberately stricter than pi's own CLI, which also
15
+ * substring-matches. We store a canonical `provider/id`, so exact is the only
16
+ * match that should ever count.
17
+ */
18
+ export function resolveModel(ctx, spec) {
19
+ try {
20
+ const parts = spec === MODEL_INHERIT ? undefined : splitSpec(spec);
21
+ const handle = spec === MODEL_INHERIT ?
22
+ ctx.model
23
+ : parts && ctx.modelRegistry?.find(parts.provider, parts.id);
24
+ if (!handle)
25
+ return undefined;
26
+ const provider = parts?.provider ?? handle.provider;
27
+ const extensionProviders = ctx.modelRegistry?.getRegisteredProviderIds?.() ?? [];
28
+ return {
29
+ spec: parts ? spec : specOf(handle),
30
+ name: handle.name || handle.id,
31
+ reasoning: handle.reasoning,
32
+ ...(handle.thinkingLevelMap === undefined ?
33
+ {}
34
+ : { thinkingLevelMap: handle.thinkingLevelMap }),
35
+ ...(handle.baseUrl ? { baseUrl: handle.baseUrl } : {}),
36
+ contextWindow: handle.contextWindow ?? 0,
37
+ fromExtension: extensionProviders.includes(provider),
38
+ handle
39
+ };
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ /**
46
+ * Every group's cell in ONE registry walk, so the argv drop, the churn window
47
+ * and the hint can never disagree about which model a group runs on.
48
+ *
49
+ * An `inherit` cell gets NO window. Storing the parent's would freeze a
50
+ * session_start snapshot in front of the live per-run value: a user who
51
+ * switches the session model with Ctrl+P to a bigger one would have every
52
+ * child judged against the old window, and the churn rule then fires early and
53
+ * kills a healthy child. An unresolved cell gets none for the same reason —
54
+ * such a child runs on the live default, whichever that is by then.
55
+ *
56
+ * A registry that cannot answer condemns NOTHING: every cell stays usable and
57
+ * carries no window. Claiming every spec unresolved would drop every `--model`
58
+ * on a session whose runtime merely was not ready.
59
+ */
60
+ export function resolveGroupModels(ctx, specs) {
61
+ const registry = readRegistry(ctx);
62
+ const cell = (spec) => {
63
+ if (spec === MODEL_INHERIT || registry === undefined)
64
+ return { spec, usable: true };
65
+ const found = resolveModel({ modelRegistry: registry }, spec);
66
+ if (!found)
67
+ return { spec, usable: false, problem: 'unresolved' };
68
+ return {
69
+ spec,
70
+ usable: true,
71
+ ...(found.contextWindow > 0 ? { contextWindow: found.contextWindow } : {}),
72
+ ...(found.fromExtension ? { problem: 'extension' } : {})
73
+ };
74
+ };
75
+ return Object.fromEntries(CHILD_GROUPS.map(g => [g, cell(specs[g])]));
76
+ }
77
+ /**
78
+ * `spec → baseUrl` for every model this session can use, for the dead-backend
79
+ * probe (shared/model-endpoint.ts). Empty when the registry cannot answer,
80
+ * which the probe reads as "cannot see, so never kill".
81
+ */
82
+ export function resolveModelEndpoints(ctx) {
83
+ const out = new Map();
84
+ for (const m of readRegistry(ctx)?.getAvailable?.() ?? []) {
85
+ if (m.baseUrl)
86
+ out.set(specOf(m), m.baseUrl);
87
+ }
88
+ return out;
89
+ }
90
+ function readRegistry(ctx) {
91
+ try {
92
+ return ctx.modelRegistry;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
@@ -67,6 +67,26 @@ export declare function supportedThinkingLevels(model: ReasoningModelFacts): Lad
67
67
  * the model supports it — which is what makes the inequality a mismatch test.
68
68
  */
69
69
  export declare function clampToModel(model: ReasoningModelFacts, level: LadderLevel): LadderLevel;
70
+ /**
71
+ * The settings a /task-config row may offer, given the model its group runs on.
72
+ *
73
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
74
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
75
+ * the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
76
+ * may not offer them. A model declaring `xhigh` must not smuggle it in.
77
+ */
78
+ export declare function offeredLevels(facts: ReasoningModelFacts | undefined): GroupSetting[];
79
+ /**
80
+ * The setting a row will really run at, inside the menu's own vocabulary.
81
+ *
82
+ * ONE function for the picker's preselect and the writer, because they used to
83
+ * be two copies of the same clamp and only one re-projected into
84
+ * {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
85
+ * ladder, so a model declaring `xhigh` can land on a level the menu excludes;
86
+ * a writer that stored it would put a value in the table that no row can show.
87
+ * The highest OFFERED level is the honest neighbour of an excluded one.
88
+ */
89
+ export declare function effectiveSetting(facts: ReasoningModelFacts | undefined, wanted: GroupSetting): GroupSetting;
70
90
  /** One group whose configured setting the model it runs on will not honour. */
71
91
  export interface ReasoningMismatch {
72
92
  group: ChildGroup;
@@ -30,7 +30,7 @@
30
30
  * line-for-line identical to it, ladder included. Nothing here imports pi-ai, so
31
31
  * an upstream change will not fail a test — the two have to be re-compared.
32
32
  */
33
- import { CHILD_GROUPS } from '../config/reasoning.js';
33
+ import { CHILD_GROUPS, REASONING_SETTINGS } from '../config/reasoning.js';
34
34
  /**
35
35
  * pi's own level ladder, in order — the same seven names, in the same sequence,
36
36
  * as `EXTENDED_THINKING_LEVELS` in pi-ai's models.js. The order IS the algorithm:
@@ -86,6 +86,37 @@ export function clampToModel(model, level) {
86
86
  }
87
87
  return available[0] ?? 'off';
88
88
  }
89
+ /**
90
+ * The settings a /task-config row may offer, given the model its group runs on.
91
+ *
92
+ * The INTERSECTION with `REASONING_SETTINGS`, not `supportedThinkingLevels`
93
+ * directly: that returns the whole ladder including `xhigh` and `max`, which
94
+ * the menu excludes on purpose (see config/reasoning.ts) because pi's own UI
95
+ * may not offer them. A model declaring `xhigh` must not smuggle it in.
96
+ */
97
+ export function offeredLevels(facts) {
98
+ if (facts === undefined)
99
+ return [...REASONING_SETTINGS];
100
+ const supported = supportedThinkingLevels(facts);
101
+ return REASONING_SETTINGS.filter(s => s === 'inherit' || supported.includes(s));
102
+ }
103
+ /**
104
+ * The setting a row will really run at, inside the menu's own vocabulary.
105
+ *
106
+ * ONE function for the picker's preselect and the writer, because they used to
107
+ * be two copies of the same clamp and only one re-projected into
108
+ * {@link offeredLevels}. `clampToModel` walks UP first and knows the whole
109
+ * ladder, so a model declaring `xhigh` can land on a level the menu excludes;
110
+ * a writer that stored it would put a value in the table that no row can show.
111
+ * The highest OFFERED level is the honest neighbour of an excluded one.
112
+ */
113
+ export function effectiveSetting(facts, wanted) {
114
+ if (facts === undefined || wanted === 'inherit')
115
+ return wanted;
116
+ const offered = offeredLevels(facts);
117
+ const clamped = clampToModel(facts, wanted);
118
+ return offered.includes(clamped) ? clamped : (offered.at(-1) ?? 'inherit');
119
+ }
89
120
  /**
90
121
  * Every group whose setting the model IT RUNS ON will silently change.
91
122
  *
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Dead-backend stall probe — the third child guard machine, beside the stream and
3
+ * command watchdogs.
4
+ *
5
+ * The failure it serves: the model server dies mid-child and the child hangs
6
+ * MUTE. pi's own connection handling runs from a catch, so a request that never
7
+ * answers never reaches it. Silence alone is not evidence — prompt processing
8
+ * legitimately emits nothing for minutes — so only "no output for `afterMs` AND
9
+ * the endpoint does not answer a probe" counts as a dead backend.
10
+ *
11
+ * Liveness is OUTPUT PROGRESS: any chunk resets the window. A reachable probe
12
+ * also resets it, so the next probe is a full window away rather than every
13
+ * tick, and a probe that itself throws proves nothing and is treated as
14
+ * reachable. The machine takes its clock and scheduler as deps for the same
15
+ * reason the two watchdogs do: so it can be driven by a fake in a unit test
16
+ * instead of only through a real spawn.
17
+ */
18
+ import type { TimerHandle } from './command-watchdog.js';
19
+ export interface StallProbeDeps {
20
+ /** Silence that triggers a probe, in ms. */
21
+ afterMs: number;
22
+ /** true → the model endpoint answered; false → it did not. */
23
+ probe: () => Promise<boolean>;
24
+ now: () => number;
25
+ schedule: (fn: () => void, ms: number) => TimerHandle;
26
+ cancel: (handle: TimerHandle) => void;
27
+ /** Fires ONCE, when the window elapsed and the probe found nobody home. */
28
+ onDead: () => void;
29
+ }
30
+ /**
31
+ * Half the window, clamped. Never tighter than 50ms, never looser than 15s: a
32
+ * probe is one network call, so it must not be re-issued every tick, and a
33
+ * window of minutes must still be noticed within seconds of elapsing.
34
+ */
35
+ export declare function stallPollIntervalMs(afterMs: number): number;
36
+ export declare class StallProbe {
37
+ private readonly deps;
38
+ private timer;
39
+ private lastActivity;
40
+ private probing;
41
+ private dead;
42
+ constructor(deps: StallProbeDeps);
43
+ start(): void;
44
+ /** Any output from the child: the window starts over. */
45
+ note(): void;
46
+ stop(): void;
47
+ /** @internal Exposed for the poll callback and tests. */
48
+ check(): Promise<void>;
49
+ }
50
+ /** Real-clock poll deps. REF'd for the reason stream-watchdog.ts records. */
51
+ export declare const realStallTimerDeps: Pick<StallProbeDeps, 'now' | 'schedule' | 'cancel'>;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Dead-backend stall probe — the third child guard machine, beside the stream and
3
+ * command watchdogs.
4
+ *
5
+ * The failure it serves: the model server dies mid-child and the child hangs
6
+ * MUTE. pi's own connection handling runs from a catch, so a request that never
7
+ * answers never reaches it. Silence alone is not evidence — prompt processing
8
+ * legitimately emits nothing for minutes — so only "no output for `afterMs` AND
9
+ * the endpoint does not answer a probe" counts as a dead backend.
10
+ *
11
+ * Liveness is OUTPUT PROGRESS: any chunk resets the window. A reachable probe
12
+ * also resets it, so the next probe is a full window away rather than every
13
+ * tick, and a probe that itself throws proves nothing and is treated as
14
+ * reachable. The machine takes its clock and scheduler as deps for the same
15
+ * reason the two watchdogs do: so it can be driven by a fake in a unit test
16
+ * instead of only through a real spawn.
17
+ */
18
+ /**
19
+ * Half the window, clamped. Never tighter than 50ms, never looser than 15s: a
20
+ * probe is one network call, so it must not be re-issued every tick, and a
21
+ * window of minutes must still be noticed within seconds of elapsing.
22
+ */
23
+ export function stallPollIntervalMs(afterMs) {
24
+ return Math.max(50, Math.min(afterMs / 2, 15_000));
25
+ }
26
+ export class StallProbe {
27
+ deps;
28
+ timer;
29
+ lastActivity = 0;
30
+ probing = false;
31
+ dead = false;
32
+ constructor(deps) {
33
+ this.deps = deps;
34
+ }
35
+ start() {
36
+ if (this.timer !== undefined)
37
+ return;
38
+ this.lastActivity = this.deps.now();
39
+ this.timer = this.deps.schedule(() => void this.check(), stallPollIntervalMs(this.deps.afterMs));
40
+ }
41
+ /** Any output from the child: the window starts over. */
42
+ note() {
43
+ this.lastActivity = this.deps.now();
44
+ }
45
+ stop() {
46
+ if (this.timer !== undefined)
47
+ this.deps.cancel(this.timer);
48
+ this.timer = undefined;
49
+ }
50
+ /** @internal Exposed for the poll callback and tests. */
51
+ async check() {
52
+ if (this.probing || this.dead)
53
+ return;
54
+ if (this.deps.now() - this.lastActivity < this.deps.afterMs)
55
+ return;
56
+ this.probing = true;
57
+ let reachable;
58
+ try {
59
+ reachable = await this.deps.probe();
60
+ }
61
+ catch {
62
+ reachable = true;
63
+ }
64
+ this.probing = false;
65
+ if (reachable) {
66
+ this.lastActivity = this.deps.now();
67
+ return;
68
+ }
69
+ this.dead = true;
70
+ this.stop();
71
+ this.deps.onDead();
72
+ }
73
+ }
74
+ /** Real-clock poll deps. REF'd for the reason stream-watchdog.ts records. */
75
+ export const realStallTimerDeps = {
76
+ now: () => Date.now(),
77
+ schedule: (fn, ms) => setInterval(fn, ms),
78
+ cancel: handle => clearInterval(handle)
79
+ };