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