@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.
- package/dist/shared/child-process.d.ts +10 -0
- package/dist/shared/child-process.js +19 -0
- package/dist/shared/command-watchdog.d.ts +9 -3
- package/dist/shared/command-watchdog.js +10 -11
- package/dist/shared/stream-watchdog.d.ts +17 -3
- package/dist/shared/stream-watchdog.js +18 -11
- package/dist/workers/pi-worker-core.d.ts +10 -0
- package/dist/workers/pi-worker-core.js +35 -1
- package/package.json +1 -1
|
@@ -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
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
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.
|
|
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",
|