@mjasnikovs/pi-task 0.24.1 → 0.24.3

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.
@@ -165,6 +165,16 @@ export declare class JsonEventSink {
165
165
  * model/provider failed (disconnect, fetch failed, socket hang up, 5xx)
166
166
  * after pi exhausted its internal retries. Holds the provider's errorMessage
167
167
  * so callers can report the real cause instead of an empty completion.
168
+ *
169
+ * CLEARED when a LATER agent_end delivers assistant text: pi retries a failed
170
+ * turn itself (`auto_retry_start`) and each attempt emits its own agent_end,
171
+ * so a recovered blip arrives as agent_end(stopReason "error", empty) followed
172
+ * by agent_end(text). Measured live against a proxy that drops the first
173
+ * connection: pi makes up to 4 attempts over ~15s, and on attempts 1–3 the
174
+ * child returns the real answer WITH the dead first attempt's errorMessage
175
+ * still in the stream. Latching that would report a failure for a run that
176
+ * succeeded. An error AFTER the last text-bearing turn still latches — that
177
+ * one really did lose the tail of the work.
168
178
  */
169
179
  modelError: string | undefined;
170
180
  private textDeltaAccum;
@@ -30,6 +30,16 @@ export class JsonEventSink {
30
30
  * model/provider failed (disconnect, fetch failed, socket hang up, 5xx)
31
31
  * after pi exhausted its internal retries. Holds the provider's errorMessage
32
32
  * so callers can report the real cause instead of an empty completion.
33
+ *
34
+ * CLEARED when a LATER agent_end delivers assistant text: pi retries a failed
35
+ * turn itself (`auto_retry_start`) and each attempt emits its own agent_end,
36
+ * so a recovered blip arrives as agent_end(stopReason "error", empty) followed
37
+ * by agent_end(text). Measured live against a proxy that drops the first
38
+ * connection: pi makes up to 4 attempts over ~15s, and on attempts 1–3 the
39
+ * child returns the real answer WITH the dead first attempt's errorMessage
40
+ * still in the stream. Latching that would report a failure for a run that
41
+ * succeeded. An error AFTER the last text-bearing turn still latches — that
42
+ * one really did lose the tail of the work.
33
43
  */
34
44
  modelError = undefined;
35
45
  textDeltaAccum = '';
@@ -103,6 +113,10 @@ export class JsonEventSink {
103
113
  return;
104
114
  }
105
115
  if (t === 'agent_end' && Array.isArray(evt.messages)) {
116
+ // Errors latched by THIS batch describe a turn that failed AFTER the
117
+ // text found below it (the scan runs backwards), so they survive; only
118
+ // an error from an earlier agent_end is cleared by a later answer.
119
+ let latchedHere = false;
106
120
  for (let i = evt.messages.length - 1; i >= 0; i--) {
107
121
  const m = evt.messages[i];
108
122
  if (!m || m.role !== 'assistant')
@@ -117,6 +131,7 @@ export class JsonEventSink {
117
131
  && m.errorMessage.length > 0
118
132
  && this.modelError === undefined) {
119
133
  this.modelError = m.errorMessage;
134
+ latchedHere = true;
120
135
  }
121
136
  if (Array.isArray(m.content)) {
122
137
  const texts = [];
@@ -127,6 +142,10 @@ export class JsonEventSink {
127
142
  }
128
143
  if (texts.length > 0) {
129
144
  this.finalText = texts.join('');
145
+ // This turn answered, so an error latched by an EARLIER
146
+ // agent_end was a blip pi retried past — drop it.
147
+ if (!latchedHere)
148
+ this.modelError = undefined;
130
149
  break;
131
150
  }
132
151
  }
@@ -96,8 +96,14 @@ export declare class CommandWatchdog {
96
96
  private fire;
97
97
  }
98
98
  /**
99
- * Real-clock schedule/cancel, unref'd so a pending watchdog timer can never
100
- * itself keep the process alive on exit. Shared by both adapters; tests
101
- * substitute a fake scheduler instead.
99
+ * Real-clock schedule/cancel. Shared by both adapters; tests substitute a fake
100
+ * scheduler instead.
101
+ *
102
+ * REF'd, for the same measured reason as the stream watchdog's poll (see
103
+ * realStreamTimerDeps): under Bun on windows an unref'd timer never fires once
104
+ * nothing ref'd is pending, and a child sitting in a hung command is exactly
105
+ * that state — so the unref disabled the guard in the one case it exists for.
106
+ * The timer is cleared when the tool ends and in runWorker's `finally`, so it
107
+ * cannot outlive the call it watches.
102
108
  */
103
109
  export declare const realTimerDeps: Pick<WatchdogDeps, 'schedule' | 'cancel'>;
@@ -141,18 +141,17 @@ export class CommandWatchdog {
141
141
  }
142
142
  }
143
143
  /**
144
- * Real-clock schedule/cancel, unref'd so a pending watchdog timer can never
145
- * itself keep the process alive on exit. Shared by both adapters; tests
146
- * substitute a fake scheduler instead.
144
+ * Real-clock schedule/cancel. Shared by both adapters; tests substitute a fake
145
+ * scheduler instead.
146
+ *
147
+ * REF'd, for the same measured reason as the stream watchdog's poll (see
148
+ * realStreamTimerDeps): under Bun on windows an unref'd timer never fires once
149
+ * nothing ref'd is pending, and a child sitting in a hung command is exactly
150
+ * that state — so the unref disabled the guard in the one case it exists for.
151
+ * The timer is cleared when the tool ends and in runWorker's `finally`, so it
152
+ * cannot outlive the call it watches.
147
153
  */
148
154
  export const realTimerDeps = {
149
- schedule: (fn, ms) => {
150
- const handle = setTimeout(fn, ms);
151
- if (typeof handle.unref === 'function') {
152
- ;
153
- handle.unref();
154
- }
155
- return handle;
156
- },
155
+ schedule: (fn, ms) => setTimeout(fn, ms),
157
156
  cancel: handle => clearTimeout(handle)
158
157
  };
@@ -103,9 +103,23 @@ export declare class StreamWatchdog {
103
103
  check(): void;
104
104
  }
105
105
  /**
106
- * Real-clock poll deps, unref'd so a pending watchdog poll can never itself keep
107
- * the process alive on exit. `schedule` returns a repeating interval the
108
- * machine cancels it on stop/fire.
106
+ * Real-clock poll deps. `schedule` returns a repeating interval the machine
107
+ * cancels it on stop/fire, and runChild's cleanup() stops the watchdog on every
108
+ * settle path (close, error, abort), so it cannot outlive the child it watches.
109
+ *
110
+ * The poll is REF'd, deliberately. It used to be unref'd, on the reasoning that a
111
+ * pending watchdog poll should never keep the process alive at exit — but under
112
+ * Bun on WINDOWS an unref'd timer does not fire at all once nothing ref'd is
113
+ * pending. Measured on a windows-latest runner (bun 1.3.14): an unref'd interval
114
+ * with nothing else pending never fired in 20s, while the identical ref'd one
115
+ * fired at 101ms; on linux both fire at 100ms.
116
+ *
117
+ * That state — no ref'd work outstanding — is EXACTLY the state this watchdog
118
+ * exists to police: a child whose model stream has gone silent. So the unref
119
+ * disabled the guard precisely when it was needed, and every stream-stall
120
+ * integration test hung forever on windows (CI runs 30476786365, 30477802633,
121
+ * 30478455402 — it hangs at v0.24.1 too, so this predates that release).
122
+ * A poll that can delay exit by one interval is the cheaper failure.
109
123
  */
110
124
  export declare const realStreamTimerDeps: Pick<StreamWatchdogDeps, 'now' | 'schedule' | 'cancel'>;
111
125
  /**
@@ -148,20 +148,27 @@ export class StreamWatchdog {
148
148
  }
149
149
  }
150
150
  /**
151
- * Real-clock poll deps, unref'd so a pending watchdog poll can never itself keep
152
- * the process alive on exit. `schedule` returns a repeating interval the
153
- * machine cancels it on stop/fire.
151
+ * Real-clock poll deps. `schedule` returns a repeating interval the machine
152
+ * cancels it on stop/fire, and runChild's cleanup() stops the watchdog on every
153
+ * settle path (close, error, abort), so it cannot outlive the child it watches.
154
+ *
155
+ * The poll is REF'd, deliberately. It used to be unref'd, on the reasoning that a
156
+ * pending watchdog poll should never keep the process alive at exit — but under
157
+ * Bun on WINDOWS an unref'd timer does not fire at all once nothing ref'd is
158
+ * pending. Measured on a windows-latest runner (bun 1.3.14): an unref'd interval
159
+ * with nothing else pending never fired in 20s, while the identical ref'd one
160
+ * fired at 101ms; on linux both fire at 100ms.
161
+ *
162
+ * That state — no ref'd work outstanding — is EXACTLY the state this watchdog
163
+ * exists to police: a child whose model stream has gone silent. So the unref
164
+ * disabled the guard precisely when it was needed, and every stream-stall
165
+ * integration test hung forever on windows (CI runs 30476786365, 30477802633,
166
+ * 30478455402 — it hangs at v0.24.1 too, so this predates that release).
167
+ * A poll that can delay exit by one interval is the cheaper failure.
154
168
  */
155
169
  export const realStreamTimerDeps = {
156
170
  now: () => Date.now(),
157
- schedule: (fn, ms) => {
158
- const handle = setInterval(fn, ms);
159
- if (typeof handle.unref === 'function') {
160
- ;
161
- handle.unref();
162
- }
163
- return handle;
164
- },
171
+ schedule: (fn, ms) => setInterval(fn, ms),
165
172
  cancel: handle => clearInterval(handle)
166
173
  };
167
174
  /**
@@ -86,6 +86,16 @@ export interface RunWorkerInput {
86
86
  * 0 / omitted = off.
87
87
  */
88
88
  streamInactivityMs?: number;
89
+ /** Backoff sleep, injectable so tests don't wait out the real delays. */
90
+ sleepFor?: (ms: number) => Promise<void>;
91
+ /**
92
+ * Connection-error restart budget. Defaults to MAX_LOOP_RESTARTS, and even
93
+ * then the SHARED `restarts` counter is what actually binds — a worker that
94
+ * already spent the budget looping does not get extra lives here. 0 turns the
95
+ * retry off, which is how scripts/connection-retry-ab.ts gets a baseline arm
96
+ * out of a build that already ships the retry.
97
+ */
98
+ connectionRetries?: number;
89
99
  }
90
100
  export interface RunWorkerResult {
91
101
  text: string;
@@ -3,7 +3,7 @@ import { runChildDefault } from '../shared/child-process.js';
3
3
  import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/command-watchdog.js';
4
4
  import { childBaseArgs } from '../shared/child-extensions.js';
5
5
  import { LoopDetector } from '../task/loop-detector.js';
6
- import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
6
+ import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
7
7
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
8
8
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
9
9
  import { streamStallHint } from '../shared/stream-watchdog.js';
@@ -66,6 +66,7 @@ const STALL_AFTER_MS = 180_000;
66
66
  const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
67
67
  + 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
68
68
  + 'then write your answer now. Do not re-explore ground you have already covered.]';
69
+ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
69
70
  /**
70
71
  * Combine an external abort signal with an internal wall-clock timeout into one
71
72
  * signal, while keeping the two causes distinguishable: `timedOut()` is true
@@ -192,6 +193,9 @@ export async function runWorker(input) {
192
193
  // `restarts` (the shared budget) so a loop-caused restart doesn't shorten
193
194
  // the rope of a child that has never hung (see commandCeilingForAttempt).
194
195
  let hangKills = 0;
196
+ // Connection-error restarts specifically — drives the backoff schedule (and
197
+ // lets a harness set the budget to 0 without touching the shared counter).
198
+ let connRetries = 0;
195
199
  let leakRetries = 0;
196
200
  for (;;) {
197
201
  const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
@@ -315,6 +319,36 @@ export async function runWorker(input) {
315
319
  restarts++;
316
320
  continue;
317
321
  }
322
+ // A connection-class model error is restartable on the same budget, exactly
323
+ // as runPhaseWithLoopGuard already treats it — a research worker had no such
324
+ // retry, so one dropped fetch failed the whole task at research while the
325
+ // identical blip in refine/compose was absorbed.
326
+ //
327
+ // What this can and cannot buy, measured (flaky proxy in front of the local
328
+ // llama-server, dropping every connection for a fixed outage window): pi
329
+ // retries a failed turn itself, 4 attempts over ~15s, and a run that
330
+ // recovers no longer reports modelError at all (see JsonEventSink). So a
331
+ // surfaced connection error means pi's own ~15s budget is already spent, and
332
+ // a re-spawn only helps when the outage outlasts it. It does: at a 20s
333
+ // outage the baseline never recovered and this policy always did, 0/8 → 8/8
334
+ // (Fisher p=0.00016), and the same at 35s. Below ~15s pi absorbs it alone —
335
+ // 8/8 both arms, so the retry neither helps nor costs there. Beyond ~46s
336
+ // (three spawns' combined budget) both arms fail. The price is paid only on
337
+ // a backend that is really gone: time-to-report goes ~15s → ~46s. Re-run:
338
+ // scripts/connection-retry-ab.ts.
339
+ //
340
+ // Connection class ONLY. Auth, bad request and context overflow still fail
341
+ // fast: re-issuing the same request cannot fix them, so spending the budget
342
+ // would only delay the report.
343
+ if (result.modelError
344
+ && isConnectionError(result.modelError)
345
+ && restarts < MAX_LOOP_RESTARTS
346
+ && connRetries < (input.connectionRetries ?? MAX_LOOP_RESTARTS)) {
347
+ await (input.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(connRetries));
348
+ restarts++;
349
+ connRetries++;
350
+ continue;
351
+ }
318
352
  // Only treat output as a leak on a clean, complete run — a non-zero exit
319
353
  // or abort yields partial text the caller already handles, and detecting
320
354
  // there would just mislabel the real failure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.24.1",
3
+ "version": "0.24.3",
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",