@mjasnikovs/pi-task 0.18.26 → 0.18.28

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.
@@ -1,5 +1,6 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { runChildDefault } from '../shared/child-process.js';
3
+ import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/command-watchdog.js';
3
4
  import { childBaseArgs } from '../shared/child-extensions.js';
4
5
  import { LoopDetector } from '../task/loop-detector.js';
5
6
  import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
@@ -32,7 +33,12 @@ const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
32
33
  * alive) just gets probed and waits on.
33
34
  */
34
35
  const STALL_AFTER_MS = 180_000;
35
- /** Restart hint after a wall-clock timeout — distinct from the loop hint. */
36
+ /**
37
+ * Restart hint after a WHOLE-WORKER wall-clock timeout — distinct from both the
38
+ * loop hint and the per-command hint. This one diagnoses over-exploration, which
39
+ * is what the whole-worker cap actually catches. A single hung COMMAND is a
40
+ * different fault with a different fix, and gets commandTimeoutHint instead.
41
+ */
36
42
  const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
37
43
  + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
38
44
  + 'then write your answer now. Do not re-explore ground you have already covered.]';
@@ -69,6 +75,85 @@ function workerTimeout(external, ms) {
69
75
  }
70
76
  };
71
77
  }
78
+ /**
79
+ * The per-command ceiling for attempt N, halving each time a hang recurs.
80
+ *
81
+ * The first attempt gets the full configured ceiling — a genuinely slow build or
82
+ * test suite deserves it. But every hang-caused restart carries
83
+ * commandTimeoutHint, which tells the model in as many words to bound its
84
+ * command; a SECOND hang means it ignored an explicit instruction, and a third
85
+ * means it ignored it twice. Giving a non-complying child the full ceiling again
86
+ * would put the worst case at 3 × 15 min = 45 minutes of dead time, resting
87
+ * entirely on the model obeying prose. Halving bounds it at ~26 min while
88
+ * costing a complying child nothing.
89
+ *
90
+ * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
91
+ * restart budget is shared with loop kills, and a child restarted for LOOPING
92
+ * never received the bound-your-command hint, so its first hang still deserves
93
+ * the full ceiling. Only a hang after a hang is defiance.
94
+ *
95
+ * Floored at 30s so repeated halving cannot shrink the ceiling to something no
96
+ * real command could finish inside — but never ABOVE the configured ceiling
97
+ * itself, or a caller asking for 10s would silently get 30.
98
+ */
99
+ export function commandCeilingForAttempt(baseMs, priorHangs) {
100
+ if (!(baseMs > 0))
101
+ return 0;
102
+ const floor = Math.min(baseMs, 30_000);
103
+ return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
104
+ }
105
+ /**
106
+ * Build the child-side command watchdog for ONE attempt: a per-tool-call timer
107
+ * machine (shared with the main session) whose `onFire` aborts `signal`, which
108
+ * runChild turns into a process-GROUP kill — reaping the hung command itself,
109
+ * not just the pi child holding it.
110
+ *
111
+ * LIMIT: the group kill only reaches processes still IN the group. A hung
112
+ * command that detached a daemon (setsid/nohup dev server) leaves it running —
113
+ * the fresh attempt can then hit a port the dead attempt's escapee still holds
114
+ * (the run-9 orphan-dev-server → false-EADDRINUSE shape). No cheap fix from
115
+ * here; the restart hint's "check current state" line is the mitigation.
116
+ *
117
+ * Returns null when the watchdog is off, so the caller keeps the plain timeout
118
+ * signal and no per-call bookkeeping happens at all.
119
+ */
120
+ function commandWatch(timeoutMs) {
121
+ if (!(timeoutMs > 0))
122
+ return null;
123
+ const ctrl = new AbortController();
124
+ // pi's toolCallId pairs start↔end. When it is absent (a fake stream in a
125
+ // test, an older pi), fall back to one shared slot: tool executions in a
126
+ // child are sequential, so a single slot is still correctly paired.
127
+ const key = (id) => id ?? 'anon';
128
+ const details = new Map();
129
+ let killed;
130
+ const watchdog = new CommandWatchdog({
131
+ getTimeoutMs: () => timeoutMs,
132
+ ...realTimerDeps,
133
+ onFire: (toolCallId, toolName, ms) => {
134
+ killed = {
135
+ toolName,
136
+ timeoutMs: ms,
137
+ ...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
138
+ };
139
+ ctrl.abort();
140
+ }
141
+ });
142
+ return {
143
+ onStart: call => {
144
+ const id = key(call.toolCallId);
145
+ const args = call.args;
146
+ if (typeof args?.command === 'string') {
147
+ details.set(id, args.command.slice(0, 120));
148
+ }
149
+ watchdog.onStart(id, call.name);
150
+ },
151
+ onEnd: id => watchdog.onEnd(key(id)),
152
+ killed: () => killed,
153
+ signal: ctrl.signal,
154
+ clear: () => watchdog.clearAll()
155
+ };
156
+ }
72
157
  export async function runWorker(input) {
73
158
  const tools = input.tools ?? DEFAULT_TOOLS;
74
159
  const baseArgs = [...childBaseArgs(input.extensions ?? []), '--mode', 'json', '--tools', tools];
@@ -79,6 +164,10 @@ export async function runWorker(input) {
79
164
  // hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
80
165
  // keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
81
166
  let restarts = 0;
167
+ // Watchdog kills specifically — drives the ceiling halving. Kept apart from
168
+ // `restarts` (the shared budget) so a loop-caused restart doesn't shorten
169
+ // the rope of a child that has never hung (see commandCeilingForAttempt).
170
+ let hangKills = 0;
82
171
  let leakRetries = 0;
83
172
  for (;;) {
84
173
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
@@ -101,9 +190,13 @@ export async function runWorker(input) {
101
190
  // caller couldn't distinguish from a crash.
102
191
  let loopHit;
103
192
  const timeout = workerTimeout(input.signal, timeoutMs);
193
+ // Per-tool-call watchdog for this attempt (null when off). Its abort is
194
+ // OR'd with the worker timeout / external cancel into the child's signal.
195
+ const cmdWatch = commandWatch(commandCeilingForAttempt(input.commandTimeoutMs ?? 0, hangKills));
196
+ const childSignal = cmdWatch ? AbortSignal.any([timeout.signal, cmdWatch.signal]) : timeout.signal;
104
197
  let result;
105
198
  try {
106
- result = await runChildDefault(invocation, input.cwd, timeout.signal, {
199
+ result = await runChildDefault(invocation, input.cwd, childSignal, {
107
200
  mode: 'json-events',
108
201
  ...(input.stall === false ?
109
202
  {}
@@ -116,6 +209,7 @@ export async function runWorker(input) {
116
209
  }),
117
210
  onFirstByte: () => (tFirstByte = Date.now()),
118
211
  onToolCall: call => {
212
+ cmdWatch?.onStart(call);
119
213
  if (!loopDetector)
120
214
  return null;
121
215
  const hit = loopDetector.record(call);
@@ -124,18 +218,28 @@ export async function runWorker(input) {
124
218
  return hit;
125
219
  },
126
220
  onLine: input.onLine,
127
- onToolResult: input.onToolResult,
221
+ // Always wired when the watchdog is on — the sink only emits
222
+ // tool_execution_end if a handler exists, and without it every
223
+ // timer would stay armed and fire on a finished command.
224
+ onToolResult: cmdWatch ?
225
+ r => {
226
+ cmdWatch.onEnd(r.toolCallId);
227
+ input.onToolResult?.(r);
228
+ }
229
+ : input.onToolResult,
128
230
  onContextUsage: input.onContextUsage
129
231
  }, input.spawn);
130
232
  }
131
233
  finally {
132
234
  timeout.cleanup();
235
+ cmdWatch?.clear();
133
236
  }
134
237
  const tEnd = Date.now();
135
238
  const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
136
239
  const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
137
240
  const text = result.text ?? '';
138
241
  const timedOut = timeout.timedOut();
242
+ const commandKill = cmdWatch?.killed();
139
243
  // A loop-kill gets the same restart-with-hint treatment every other phase
140
244
  // already gets (runPhaseWithLoopGuard) — name the offending call so the
141
245
  // re-spawn avoids it. Bounded by the shared restart budget.
@@ -144,6 +248,23 @@ export async function runWorker(input) {
144
248
  restarts++;
145
249
  continue;
146
250
  }
251
+ // A hung COMMAND is restartable too, on the same budget, but checked
252
+ // before the whole-worker timeout because its hint is the specific one:
253
+ // bound the command. (The two can't be confused — a watchdog kill leaves
254
+ // timeout.timedOut() false, since that flag tracks only its own timer.)
255
+ if (commandKill && !loopHit && restarts < MAX_LOOP_RESTARTS) {
256
+ hint = commandTimeoutHint(commandKill.toolName, commandKill.timeoutMs, {
257
+ commandDetail: commandKill.detail,
258
+ // Nothing reverts the tree between attempts, so a child that can
259
+ // mutate it (edit/write, or bash side effects) must not be told
260
+ // its previous attempt left no trace. Same capability test the
261
+ // gate logger uses — decided by tools, not by phase.
262
+ editsMayPersist: /\b(?:edit|bash|write)\b/.test(tools)
263
+ });
264
+ restarts++;
265
+ hangKills++;
266
+ continue;
267
+ }
147
268
  // A wall-clock timeout (the backstop for varied thrash the exact-match
148
269
  // detector misses) is also restartable, sharing the same budget. Skip when
149
270
  // a loop also tripped — the loop hint above is more specific.
@@ -171,7 +292,15 @@ export async function runWorker(input) {
171
292
  ...(leaked ? { leakedToolCall: leaked } : {}),
172
293
  ...(loopHit ? { loopHit } : {}),
173
294
  ...(timedOut ? { timedOut: true } : {}),
174
- ...(result.stalled ? { stalled: true } : {})
295
+ ...(result.stalled ? { stalled: true } : {}),
296
+ ...(commandKill ?
297
+ {
298
+ commandTimedOut: {
299
+ toolName: commandKill.toolName,
300
+ timeoutMs: commandKill.timeoutMs
301
+ }
302
+ }
303
+ : {})
175
304
  };
176
305
  }
177
306
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.18.26",
3
+ "version": "0.18.28",
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",