@mjasnikovs/pi-task 0.14.18 → 0.14.19

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.
@@ -9,6 +9,10 @@ import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from
9
9
  export declare const LOOP_WINDOW = 20;
10
10
  export declare const LOOP_THRESHOLD = 5;
11
11
  export declare const MAX_LOOP_RESTARTS = 2;
12
+ export declare function isConnectionError(cause: string): boolean;
13
+ /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
14
+ * brief saturation window can drain before we re-issue the request. */
15
+ export declare function connectionRetryBackoffMs(attempt: number): number;
12
16
  export interface PhaseRunResult {
13
17
  text: string;
14
18
  exitCode: number;
@@ -42,6 +46,9 @@ interface PhaseDeps {
42
46
  spawn?: SpawnFn;
43
47
  /** Write a timestamped line to the per-task debug log. Fire-and-forget. */
44
48
  logDebug?: (msg: string) => void;
49
+ /** Injectable delay for connection-error backoff; defaults to a real timer.
50
+ * Tests override it with a no-op so retries don't actually sleep. */
51
+ sleepFor?: (ms: number) => Promise<void>;
45
52
  }
46
53
  export type { PhaseDeps };
47
54
  /**
@@ -17,6 +17,32 @@ import { readSection, setTaskSection } from './task-io.js';
17
17
  export const LOOP_WINDOW = 20;
18
18
  export const LOOP_THRESHOLD = 5;
19
19
  export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
20
+ // MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
21
+ // ─── Connection-error retry ──────────────────────────────────────────────────
22
+ /**
23
+ * A connection-class model error is transient: a single dropped fetch to a live
24
+ * endpoint, not a repeatable mistake. On a local single-slot server (e.g.
25
+ * llama-server with `--parallel 1`) pi-task's own concurrent fan-out can briefly
26
+ * saturate the slot, and one request fails to connect even though the model is
27
+ * up and the next request succeeds. pi already retries internally, but those
28
+ * retries don't always absorb it on a saturated local server — and pi-task's
29
+ * fail-fast then kills the whole task (and, under /task-auto, the whole run) for
30
+ * a single blip. We retry these within the existing strike/leak budget.
31
+ *
32
+ * A NON-connection model error (bad request, context-length overflow, auth,
33
+ * provider 5xx that names a real fault) still fails fast: re-spawning against
34
+ * the same request won't fix it, so burning the budget only delays the report.
35
+ */
36
+ const CONNECTION_ERROR_RE = /\b(?:connection error|connection (?:lost|closed|reset|refused|aborted)|econnreset|econnrefused|econnaborted|epipe|etimedout|enetunreach|enetdown|eai_again|socket hang up|fetch failed|network (?:error|timeout)|premature close|request timed out|terminated)\b/i;
37
+ export function isConnectionError(cause) {
38
+ return CONNECTION_ERROR_RE.test(cause);
39
+ }
40
+ /** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
41
+ * brief saturation window can drain before we re-issue the request. */
42
+ export function connectionRetryBackoffMs(attempt) {
43
+ return 500 * 2 ** attempt;
44
+ }
45
+ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
20
46
  // ─── Spawn helpers ───────────────────────────────────────────────────────────
21
47
  export function childArgs(tools, prompt) {
22
48
  // `--mode json` puts the child into the structured event stream the
@@ -92,8 +118,16 @@ export async function runPhaseChild(deps, name, tools, prompt) {
92
118
  throw new Error(`${name} child failed: ${r.stderr || '(no stderr)'}`);
93
119
  }
94
120
  if (r.modelError) {
95
- // The model/provider failed (pi exited 0 with an stopReason "error"
96
- // turn). Surface the real cause and fail fast pi already retried.
121
+ // The model/provider failed (pi exited 0 with a stopReason "error"
122
+ // turn). A connection-class cause is transientretry within the
123
+ // leak budget after a backoff; anything else fails fast (pi already
124
+ // retried, and re-spawning won't fix a real fault).
125
+ if (isConnectionError(r.modelError) && attempt < MAX_LEAK_RETRIES) {
126
+ deps.logDebug?.(`${name}: connection error "${r.modelError}" — retry `
127
+ + `${attempt + 1}/${MAX_LEAK_RETRIES}`);
128
+ await (deps.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(attempt));
129
+ continue;
130
+ }
97
131
  throw new ModelError(name, r.modelError);
98
132
  }
99
133
  if (r.text.trim().length === 0) {
@@ -169,7 +203,15 @@ export async function runPhaseWithLoopGuard(deps, name, tools, buildPrompt) {
169
203
  }
170
204
  if (r.modelError) {
171
205
  // The model/provider failed (pi exited 0 with a stopReason "error"
172
- // turn). Surface the real cause and fail fast pi already retried.
206
+ // turn). A connection-class cause is transientrestart within the
207
+ // strike budget after a backoff; anything else fails fast (pi already
208
+ // retried, and re-spawning won't fix a real fault).
209
+ if (isConnectionError(r.modelError) && strike < MAX_LOOP_RESTARTS) {
210
+ deps.logDebug?.(`${name}: connection error "${r.modelError}" — restart `
211
+ + `${strike + 1}/${MAX_LOOP_RESTARTS}`);
212
+ await (deps.sleepFor ?? defaultSleep)(connectionRetryBackoffMs(strike));
213
+ continue;
214
+ }
173
215
  throw new ModelError(name, r.modelError);
174
216
  }
175
217
  if (r.text.trim().length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.14.18",
3
+ "version": "0.14.19",
4
4
  "description": "Deterministic spec-orchestration for local models, with a bundled real-time remote web view and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",