@mjasnikovs/pi-task 0.24.1 → 0.24.2

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
  }
@@ -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.2",
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",