@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.
Files changed (49) hide show
  1. package/dist/config/group-args.d.ts +24 -9
  2. package/dist/config/group-args.js +38 -28
  3. package/dist/config/register.d.ts +0 -9
  4. package/dist/config/register.js +17 -62
  5. package/dist/shared/child-process.d.ts +34 -32
  6. package/dist/shared/child-process.js +44 -58
  7. package/dist/shared/command-watchdog.d.ts +12 -4
  8. package/dist/shared/command-watchdog.js +6 -7
  9. package/dist/shared/connection-error.d.ts +7 -0
  10. package/dist/shared/connection-error.js +65 -0
  11. package/dist/shared/model-endpoint.d.ts +12 -24
  12. package/dist/shared/model-endpoint.js +32 -82
  13. package/dist/shared/model-resolve.d.ts +105 -0
  14. package/dist/shared/model-resolve.js +97 -0
  15. package/dist/shared/reasoning-capability.d.ts +20 -0
  16. package/dist/shared/reasoning-capability.js +32 -1
  17. package/dist/shared/stall-probe.d.ts +51 -0
  18. package/dist/shared/stall-probe.js +79 -0
  19. package/dist/task/child-runner.d.ts +76 -278
  20. package/dist/task/child-runner.js +186 -722
  21. package/dist/task/context-usage.js +2 -7
  22. package/dist/task/failure-classifier.js +53 -81
  23. package/dist/task/gate-child.js +1 -1
  24. package/dist/task/impl-widget.d.ts +2 -0
  25. package/dist/task/impl-widget.js +4 -0
  26. package/dist/task/implementation-hold.d.ts +11 -0
  27. package/dist/task/implementation-hold.js +20 -0
  28. package/dist/task/implementation-scope.d.ts +24 -0
  29. package/dist/task/implementation-scope.js +34 -0
  30. package/dist/task/loop-detector.d.ts +13 -5
  31. package/dist/task/loop-detector.js +11 -5
  32. package/dist/task/model-hold-stash.js +4 -14
  33. package/dist/task/orchestrator.d.ts +1 -8
  34. package/dist/task/orchestrator.js +11 -34
  35. package/dist/task/phases.js +2 -2
  36. package/dist/task/stall-detector.d.ts +1 -1
  37. package/dist/task/stall-detector.js +1 -1
  38. package/dist/workers/model-warning.d.ts +4 -16
  39. package/dist/workers/model-warning.js +14 -70
  40. package/dist/workers/pi-worker-core.d.ts +65 -20
  41. package/dist/workers/pi-worker-core.js +109 -50
  42. package/dist/workers/reasoning-warning.js +2 -24
  43. package/dist/workers/worker-failure.d.ts +2 -0
  44. package/dist/workers/worker-failure.js +2 -1
  45. package/dist/workers/worker-kill.d.ts +30 -11
  46. package/dist/workers/worker-kill.js +68 -20
  47. package/dist/workers/worker-profiles.d.ts +20 -0
  48. package/dist/workers/worker-profiles.js +22 -9
  49. package/package.json +1 -1
@@ -17,15 +17,21 @@
17
17
  * row type would need an escape hatch per row. What the orderings gain here is that
18
18
  * neither can name a cause with no row, nor silently omit one.
19
19
  *
20
- * Not every cause appears in both ladders, and that asymmetry is real. Six of the
21
- * nine are `restartable` and those six are exactly RESTART_ORDER; eight are
22
- * `reported` and those eight are exactly FAILURE_ORDER. `connection-error` is the
23
- * one that restarts without ever being reported as a kill — it reaches the caller
24
- * as a `modelError`. `stalled`, `aborted` and `exit` end an attempt outright, and
25
- * no hint would help.
20
+ * Not every cause appears in both ladders, and that asymmetry is real. Eight of
21
+ * the ten are `restartable` and those eight are exactly RESTART_ORDER; eight are
22
+ * `reported` and those eight are exactly FAILURE_ORDER. `connection-error` and
23
+ * `empty-answer` restart without ever being reported as a kill — the first
24
+ * reaches the caller as a `modelError`, the second as empty text, and whether
25
+ * either counts as a failure is the consumer's policy. `aborted` and `exit` end
26
+ * an attempt outright, and no hint would help.
27
+ *
28
+ * Whether a restartable cause actually restarts is the PROFILE's decision
29
+ * (worker-profiles.ts): `stalled` and `empty-answer` each carry a switch there,
30
+ * because the phase children re-spawn on both and the research workers on
31
+ * neither.
26
32
  */
27
33
  /** Every way a worker attempt can end other than by answering. */
28
- export type WorkerKillId = 'stalled' | 'command-timeout' | 'stream-stall' | 'worker-timeout' | 'connection-error' | 'loop' | 'leaked-tool-call' | 'aborted' | 'exit';
34
+ export type WorkerKillId = 'stalled' | 'command-timeout' | 'stream-stall' | 'worker-timeout' | 'connection-error' | 'loop' | 'leaked-tool-call' | 'empty-answer' | 'aborted' | 'exit';
29
35
  export interface WorkerKill {
30
36
  id: WorkerKillId;
31
37
  /**
@@ -54,20 +60,33 @@ export interface WorkerKill {
54
60
  restartable: boolean;
55
61
  /** Does this cause reach a consumer as a `WorkerFailure`? */
56
62
  reported: boolean;
63
+ /**
64
+ * Must a best-effort `catch` in the phase pipeline rethrow this?
65
+ *
66
+ * A child that merely answered badly should degrade — that is what those
67
+ * catches are for. A dead backend is different in kind: the run is over
68
+ * either way, and swallowing it ships a half-built spec while every later
69
+ * phase dies against the same dead server. A property of the cause, so a
70
+ * catch asks the roster rather than keeping its own list.
71
+ */
72
+ fatal: boolean;
57
73
  }
58
74
  export declare const WORKER_KILLS: readonly WorkerKill[];
59
75
  /** Look one cause up. `undefined` only for an id with no row, which the suite forbids. */
60
76
  export declare function workerKill(id: WorkerKillId): WorkerKill | undefined;
77
+ /** Takes any string: the phase failure union carries kinds that are not kills. */
78
+ export declare function isFatalKill(kind: string): boolean;
61
79
  /**
62
80
  * The restart ladder's precedence, as ids. `RESTART_RULES` must be exactly this,
63
81
  * in this order.
64
82
  *
65
83
  * `loop` leads: its hint names the offending call, which is the most useful thing
66
- * to tell a re-spawn. The two watchdogs come before the wall clock because each
67
- * is the narrower diagnosis, and they cannot be confused with it — a watchdog
68
- * kill leaves the worker's own timeout flag false.
84
+ * to tell a re-spawn. The two watchdogs and the dead-backend probe come before
85
+ * the wall clock because each is the narrower diagnosis, and they cannot be
86
+ * confused with it — a guard kill leaves the worker's own timeout flag false.
87
+ * `empty-answer` is last: like a leaked call it exists only on a clean run.
69
88
  */
70
- export declare const RESTART_ORDER: readonly ["loop", "command-timeout", "stream-stall", "worker-timeout", "connection-error", "leaked-tool-call"];
89
+ export declare const RESTART_ORDER: readonly ["loop", "command-timeout", "stream-stall", "stalled", "worker-timeout", "connection-error", "leaked-tool-call", "empty-answer"];
71
90
  /**
72
91
  * The failure ladder's precedence, as ids. `FAILURE_RULES` must be exactly this,
73
92
  * in this order.
@@ -17,80 +17,128 @@
17
17
  * row type would need an escape hatch per row. What the orderings gain here is that
18
18
  * neither can name a cause with no row, nor silently omit one.
19
19
  *
20
- * Not every cause appears in both ladders, and that asymmetry is real. Six of the
21
- * nine are `restartable` and those six are exactly RESTART_ORDER; eight are
22
- * `reported` and those eight are exactly FAILURE_ORDER. `connection-error` is the
23
- * one that restarts without ever being reported as a kill — it reaches the caller
24
- * as a `modelError`. `stalled`, `aborted` and `exit` end an attempt outright, and
25
- * no hint would help.
20
+ * Not every cause appears in both ladders, and that asymmetry is real. Eight of
21
+ * the ten are `restartable` and those eight are exactly RESTART_ORDER; eight are
22
+ * `reported` and those eight are exactly FAILURE_ORDER. `connection-error` and
23
+ * `empty-answer` restart without ever being reported as a kill — the first
24
+ * reaches the caller as a `modelError`, the second as empty text, and whether
25
+ * either counts as a failure is the consumer's policy. `aborted` and `exit` end
26
+ * an attempt outright, and no hint would help.
27
+ *
28
+ * Whether a restartable cause actually restarts is the PROFILE's decision
29
+ * (worker-profiles.ts): `stalled` and `empty-answer` each carry a switch there,
30
+ * because the phase children re-spawn on both and the research workers on
31
+ * neither.
26
32
  */
27
33
  export const WORKER_KILLS = [
28
34
  {
29
35
  id: 'stalled',
30
36
  resultField: 'stalled',
31
37
  carryForward: false,
32
- restartable: false,
33
- reported: true
38
+ restartable: true,
39
+ reported: true,
40
+ fatal: true
34
41
  },
35
42
  {
36
43
  id: 'command-timeout',
37
44
  resultField: 'commandTimedOut',
38
45
  carryForward: true,
39
46
  restartable: true,
40
- reported: true
47
+ reported: true,
48
+ fatal: false
41
49
  },
42
50
  {
43
51
  id: 'stream-stall',
44
52
  resultField: 'streamStalled',
45
53
  carryForward: true,
46
54
  restartable: true,
47
- reported: true
55
+ reported: true,
56
+ fatal: false
48
57
  },
49
58
  {
50
59
  id: 'worker-timeout',
51
60
  resultField: 'timedOut',
52
61
  carryForward: true,
53
62
  restartable: true,
54
- reported: true
63
+ reported: true,
64
+ fatal: false
55
65
  },
56
66
  {
57
67
  id: 'connection-error',
58
68
  resultField: null,
59
69
  carryForward: true,
60
70
  restartable: true,
61
- reported: false
71
+ reported: false,
72
+ fatal: false
73
+ },
74
+ {
75
+ id: 'loop',
76
+ resultField: 'loopHit',
77
+ carryForward: false,
78
+ restartable: true,
79
+ reported: true,
80
+ fatal: false
62
81
  },
63
- { id: 'loop', resultField: 'loopHit', carryForward: false, restartable: true, reported: true },
64
82
  {
65
83
  id: 'leaked-tool-call',
66
84
  resultField: 'leakedToolCall',
67
85
  carryForward: false,
68
86
  restartable: true,
69
- reported: true
87
+ reported: true,
88
+ fatal: false
89
+ },
90
+ {
91
+ id: 'empty-answer',
92
+ resultField: null,
93
+ carryForward: false,
94
+ restartable: true,
95
+ reported: false,
96
+ fatal: false
70
97
  },
71
- { id: 'aborted', resultField: null, carryForward: false, restartable: false, reported: true },
72
- { id: 'exit', resultField: null, carryForward: false, restartable: false, reported: true }
98
+ {
99
+ id: 'aborted',
100
+ resultField: null,
101
+ carryForward: false,
102
+ restartable: false,
103
+ reported: true,
104
+ fatal: false
105
+ },
106
+ {
107
+ id: 'exit',
108
+ resultField: null,
109
+ carryForward: false,
110
+ restartable: false,
111
+ reported: true,
112
+ fatal: false
113
+ }
73
114
  ];
74
115
  /** Look one cause up. `undefined` only for an id with no row, which the suite forbids. */
75
116
  export function workerKill(id) {
76
117
  return WORKER_KILLS.find(k => k.id === id);
77
118
  }
119
+ /** Takes any string: the phase failure union carries kinds that are not kills. */
120
+ export function isFatalKill(kind) {
121
+ return WORKER_KILLS.some(k => k.id === kind && k.fatal);
122
+ }
78
123
  /**
79
124
  * The restart ladder's precedence, as ids. `RESTART_RULES` must be exactly this,
80
125
  * in this order.
81
126
  *
82
127
  * `loop` leads: its hint names the offending call, which is the most useful thing
83
- * to tell a re-spawn. The two watchdogs come before the wall clock because each
84
- * is the narrower diagnosis, and they cannot be confused with it — a watchdog
85
- * kill leaves the worker's own timeout flag false.
128
+ * to tell a re-spawn. The two watchdogs and the dead-backend probe come before
129
+ * the wall clock because each is the narrower diagnosis, and they cannot be
130
+ * confused with it — a guard kill leaves the worker's own timeout flag false.
131
+ * `empty-answer` is last: like a leaked call it exists only on a clean run.
86
132
  */
87
133
  export const RESTART_ORDER = [
88
134
  'loop',
89
135
  'command-timeout',
90
136
  'stream-stall',
137
+ 'stalled',
91
138
  'worker-timeout',
92
139
  'connection-error',
93
- 'leaked-tool-call'
140
+ 'leaked-tool-call',
141
+ 'empty-answer'
94
142
  // `as const satisfies`, not an annotation: `WorkerRestartReason` is
95
143
  // `(typeof RESTART_ORDER)[number]`, and a `readonly WorkerKillId[]`
96
144
  // annotation collapses that to the whole `WorkerKillId` union — which would
@@ -89,6 +89,12 @@ export interface StalledGuard {
89
89
  * harnesses inject a real probe through the override.
90
90
  */
91
91
  probe: (() => Promise<boolean>) | null;
92
+ /**
93
+ * Re-spawn on a dead-backend kill, within the shared restart budget, so the
94
+ * verdict is earned on every attempt rather than on one probe sample. Off
95
+ * for a worker whose caller would rather hear "unreachable" at once.
96
+ */
97
+ restart: boolean;
92
98
  }
93
99
  /**
94
100
  * The whole-worker deadline. All three fields move the SAME timer, which is why
@@ -198,6 +204,14 @@ interface WorkerGuardShapes {
198
204
  'connection-error': number;
199
205
  loop: LoopGuard;
200
206
  'leaked-tool-call': null;
207
+ /**
208
+ * Re-spawn on an empty completion — exit 0, no text, no reported error —
209
+ * within the shared restart budget. For a phase child that is almost always
210
+ * a provider error swallowed inside `--mode json`, not a repeatable mistake.
211
+ * Off for the research workers, which decide what an empty section means
212
+ * themselves (research-worker.ts).
213
+ */
214
+ 'empty-answer': boolean;
201
215
  aborted: null;
202
216
  exit: null;
203
217
  }
@@ -252,6 +266,12 @@ export interface WorkerPolicyInputs {
252
266
  commandTimeoutMs?: number;
253
267
  /** gate, phase: `config.streamInactivityMs`. */
254
268
  streamInactivityMs?: number;
269
+ /**
270
+ * phase: a wall clock on ONE spawn, for a caller that genuinely wants a hard
271
+ * stop. Nothing sets it in production — see the phase row's `why` — and
272
+ * tests inject a short one.
273
+ */
274
+ timeoutMs?: number;
255
275
  /** research: only `worker:apis` fans out, so only it can be scaled. */
256
276
  fanoutBounded?: boolean;
257
277
  /** research: the env reader the fanout and progress-ceiling levers use.
@@ -102,7 +102,7 @@ export const DEFAULT_LOOP_PROGRESS = {
102
102
  */
103
103
  function baseGuards() {
104
104
  return {
105
- stalled: { afterMs: STALL_AFTER_MS, probe: null },
105
+ stalled: { afterMs: STALL_AFTER_MS, probe: null, restart: false },
106
106
  'command-timeout': 0,
107
107
  'stream-stall': 0,
108
108
  'worker-timeout': {
@@ -113,6 +113,7 @@ function baseGuards() {
113
113
  'connection-error': MAX_LOOP_RESTARTS,
114
114
  loop: { detector: { ...DEFAULT_LOOP_DETECTOR }, progress: { ...DEFAULT_LOOP_PROGRESS } },
115
115
  'leaked-tool-call': null,
116
+ 'empty-answer': false,
116
117
  aborted: null,
117
118
  exit: null
118
119
  };
@@ -213,21 +214,33 @@ export const WORKER_PROFILES = {
213
214
  + 'watchdog SUSPENDS for the duration of a tool call, the dead-backend '
214
215
  + 'probe reads a reachable endpoint as alive, and both runaway detectors '
215
216
  + 'wait on a result that never arrives. Only a user ESC could end it. '
216
- + 'The wall clock stays OFF as PHASE_CHILD_TIMEOUT_MS decided for a FIXED '
217
- + "cap; that does not settle research's progress-based ceiling. "
218
- + 'PARTIALLY CONSUMED: runPhaseChild has its own strike loop and reads '
219
- + 'only `command-timeout`, `stream-stall`, `stalled` and `loop`, so '
220
- + 'setting `worker-timeout` or `connection-error` here does NOTHING.',
217
+ + 'The wall clock is OFF. A wall clock on a model child measures the '
218
+ + "MODEL'S SPEED, not its health: the same planning child that answers "
219
+ + 'in seconds on one backend takes minutes on another, so any cap loose '
220
+ + 'enough to be safe catches nothing and any cap tight enough to catch a '
221
+ + 'runaway kills healthy work. The runaway it would catch — forward-paging '
222
+ + "through a whole context window — is the StallDetector's, which bounds "
223
+ + 'non-progress and churn rather than elapsed time. '
224
+ + 'Two departures the research workers do not share. A dead-backend kill '
225
+ + 'is RETRIED: one probe sample cannot tell a blip from a death, three '
226
+ + 'failed probes cost the stall window three times, and one wrong verdict '
227
+ + 'ends the whole run. And an EMPTY completion is retried: inside '
228
+ + '`--mode json` a swallowed provider error arrives as exit 0 with no '
229
+ + 'text, and a fresh spawn almost always answers.',
221
230
  resolve: inputs => {
222
231
  const guards = baseGuards();
223
- // INERT for this profile — runPhaseChild never reads it. Zeroed anyway
224
- // so the row cannot be mistaken for research's armed 240s cap.
225
- guards['worker-timeout'] = { timeoutMs: 0, progressCeilingMs: null, fanout: null };
232
+ guards['worker-timeout'] = {
233
+ timeoutMs: inputs.timeoutMs ?? 0,
234
+ progressCeilingMs: null,
235
+ fanout: null
236
+ };
226
237
  // Both ceilings are the user's own settings, as they are for gate: the
227
238
  // number is theirs, the decision to arm it is this row's. 0 from a
228
239
  // caller that hands none, so a harness cannot silently acquire a guard.
229
240
  guards['command-timeout'] = inputs.commandTimeoutMs ?? 0;
230
241
  guards['stream-stall'] = inputs.streamInactivityMs ?? 0;
242
+ guards.stalled = { afterMs: STALL_AFTER_MS, probe: null, restart: true };
243
+ guards['empty-answer'] = true;
231
244
  return { guards, carryForward: false };
232
245
  }
233
246
  // `as const satisfies`, not an annotation — the same reason RESTART_ORDER
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",