@mjasnikovs/pi-task 0.24.0 → 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
|
}
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -108,6 +108,21 @@ export declare function scopedToolingGoal(refined: string): string;
|
|
|
108
108
|
* partial text, so an empty degrade is never mistaken for a real finding.
|
|
109
109
|
*/
|
|
110
110
|
export declare function degradedSectionBody(name: string, reason: string, partial: string): string;
|
|
111
|
+
/**
|
|
112
|
+
* The body written for a research section the worker confirmed has no entries.
|
|
113
|
+
*
|
|
114
|
+
* Three states have to stay distinguishable to anyone — human or later phase —
|
|
115
|
+
* reading a research section, so each carries its own marker:
|
|
116
|
+
* `(none — …)` the worker RAN and answered "nothing applies" (this)
|
|
117
|
+
* `(degraded: …)` the worker was killed mid-answer, text may be partial
|
|
118
|
+
* (degradedSectionBody)
|
|
119
|
+
* section absent the worker never got that far — the phase threw
|
|
120
|
+
*
|
|
121
|
+
* Naming the worker inside the marker keeps it true after assembly, where the
|
|
122
|
+
* section headings are all that separate the four workers' output.
|
|
123
|
+
*/
|
|
124
|
+
export declare function emptySectionBody(name: string): string;
|
|
125
|
+
export declare function isBareNoneAnswer(text: string): boolean;
|
|
111
126
|
export declare function phaseResearch(deps: PhaseDeps, refined: string, researchDeps?: PhaseResearchDeps): Promise<string>;
|
|
112
127
|
export interface PhaseAutoAnswerDeps {
|
|
113
128
|
docsFocused?: typeof docsFocused;
|
package/dist/task/phases.js
CHANGED
|
@@ -55,6 +55,12 @@ export function extractToolingCommands(research) {
|
|
|
55
55
|
const line = raw.trim();
|
|
56
56
|
if (!line)
|
|
57
57
|
continue;
|
|
58
|
+
// A section MARKER describes the worker, not a command. Without this the
|
|
59
|
+
// "(none — the TOOLING worker ran …)" / "(degraded: …)" line is handed to the
|
|
60
|
+
// verify child as a command to run, which can only be rejected — noise in the
|
|
61
|
+
// prompt and one more thing that reads like a real tool in the task file.
|
|
62
|
+
if (/^\((?:none —|degraded:)/.test(line))
|
|
63
|
+
continue;
|
|
58
64
|
const match = line.match(/^\S.*?\s{2,}(.+)$/);
|
|
59
65
|
if (match) {
|
|
60
66
|
commands.push(match[1].trim());
|
|
@@ -300,11 +306,27 @@ export function scopedToolingGoal(refined) {
|
|
|
300
306
|
* (exit 143) OR a clean exit 0 with truncated text, so loopHit/timedOut — not
|
|
301
307
|
* exitCode — are the reliable signal and are checked first.
|
|
302
308
|
*
|
|
303
|
-
* - 'fatal' (non-zero exit that isn't a loop-kill,
|
|
304
|
-
* never-executed tool call): the output is
|
|
305
|
-
* can't paper over (broken env, model
|
|
306
|
-
* These still throw — degrading them
|
|
307
|
-
*
|
|
309
|
+
* - 'fatal' (non-zero exit that isn't a loop-kill, a provider error behind an
|
|
310
|
+
* empty answer, or a leaked never-executed tool call): the output is
|
|
311
|
+
* untrustworthy in a way partial text can't paper over (broken env, model
|
|
312
|
+
* disconnect, wrong tool-call dialect). These still throw — degrading them
|
|
313
|
+
* would launder a real breakage into a plausible-looking section.
|
|
314
|
+
*
|
|
315
|
+
* - 'empty' (clean exit 0, no provider error, no loop/timeout — the model simply
|
|
316
|
+
* wrote nothing): NOT a failure. On an extremely simple task ("create a folder
|
|
317
|
+
* with an index.html in it") three of the four workers have genuinely nothing
|
|
318
|
+
* to report, and each worker prompt tells the model to emit ONLY what this task
|
|
319
|
+
* touches and to drop everything else — so silence is the CORRECT answer and
|
|
320
|
+
* was killing the whole task at research (issue #10). Measured live on the
|
|
321
|
+
* issue's own prompt (30 reps/worker, local Qwen3.6-27B): every APIS answer was
|
|
322
|
+
* semantically "there is nothing here", and 2/30 were literally zero bytes on a
|
|
323
|
+
* clean exit — the other 28 survived only because the model happened to wrap the
|
|
324
|
+
* same non-answer in a parenthetical, which is model style, not signal. The
|
|
325
|
+
* caller retries once and then accepts an explicit empty section; what stays
|
|
326
|
+
* fatal is silence WITH a reported cause, which is the masked-disconnect case
|
|
327
|
+
* this branch was written for and which `modelError` now names outright.
|
|
328
|
+
*
|
|
329
|
+
* Returns null when the result is trustworthy.
|
|
308
330
|
*/
|
|
309
331
|
function classifyResearchWorker(name, result) {
|
|
310
332
|
if (result.loopHit) {
|
|
@@ -326,7 +348,33 @@ function classifyResearchWorker(name, result) {
|
|
|
326
348
|
};
|
|
327
349
|
}
|
|
328
350
|
if (result.text.trim().length === 0) {
|
|
329
|
-
|
|
351
|
+
// NOTHING CAME BACK — two different events wear the same face, and the whole
|
|
352
|
+
// point of this branch is to tell them apart:
|
|
353
|
+
//
|
|
354
|
+
// FAILED, cause reported: pi delivers a failed turn as an empty assistant
|
|
355
|
+
// message with stopReason "error" and exit 0, so the real cause used to be
|
|
356
|
+
// discarded and reported as the useless "produced no output". Name it.
|
|
357
|
+
// FAILED, child never spoke: no stdout at all means the child died before it
|
|
358
|
+
// could run (unresolvable provider, missing key, bad argv) — it never
|
|
359
|
+
// answered, so it cannot have answered "nothing".
|
|
360
|
+
// EMPTY: a child that streamed, exited 0, reported no error, and wrote no
|
|
361
|
+
// answer. The worker ran and the model had nothing to say — a real answer
|
|
362
|
+
// on a task that touches nothing, not a failure.
|
|
363
|
+
if (result.modelError) {
|
|
364
|
+
return {
|
|
365
|
+
kind: 'fatal',
|
|
366
|
+
error: new Error(`Research ${name} worker: model error — ${result.modelError.slice(0, 200)}`)
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
if (!result.sawOutput) {
|
|
370
|
+
return {
|
|
371
|
+
kind: 'fatal',
|
|
372
|
+
error: new Error(`Research ${name} worker produced no output — the child never wrote a `
|
|
373
|
+
+ 'single byte, so it died before it could answer'
|
|
374
|
+
+ (result.stderr ? `: ${result.stderr.slice(-300)}` : ''))
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
return { kind: 'empty' };
|
|
330
378
|
}
|
|
331
379
|
if (result.leakedToolCall) {
|
|
332
380
|
return {
|
|
@@ -365,6 +413,67 @@ async function manifestDependencyNames(cwd) {
|
|
|
365
413
|
return [];
|
|
366
414
|
}
|
|
367
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* The body written for a research section the worker confirmed has no entries.
|
|
418
|
+
*
|
|
419
|
+
* Three states have to stay distinguishable to anyone — human or later phase —
|
|
420
|
+
* reading a research section, so each carries its own marker:
|
|
421
|
+
* `(none — …)` the worker RAN and answered "nothing applies" (this)
|
|
422
|
+
* `(degraded: …)` the worker was killed mid-answer, text may be partial
|
|
423
|
+
* (degradedSectionBody)
|
|
424
|
+
* section absent the worker never got that far — the phase threw
|
|
425
|
+
*
|
|
426
|
+
* Naming the worker inside the marker keeps it true after assembly, where the
|
|
427
|
+
* section headings are all that separate the four workers' output.
|
|
428
|
+
*/
|
|
429
|
+
export function emptySectionBody(name) {
|
|
430
|
+
return `(none — the ${name} worker ran and reported no entries for this task)`;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* A worker answer that IS the word "nothing" and carries no other content:
|
|
434
|
+
* `(none)`, `N/A`, `- none`, `(no content)`, `(no entries)`. Live workers write
|
|
435
|
+
* these often on a task that touches nothing (measured on the issue's prompt:
|
|
436
|
+
* `(no content)`, `(no response)`, a bare `(none)` from the gate's own retry),
|
|
437
|
+
* and each one means exactly what an empty answer means — so they are recorded
|
|
438
|
+
* with the same marker rather than passed through in whatever shape the model
|
|
439
|
+
* happened to pick. Deliberately NARROW: it matches only a lone token, never
|
|
440
|
+
* prose like "(no APIs to list — this task creates a plain HTML file …)", which
|
|
441
|
+
* carries a reason worth keeping.
|
|
442
|
+
*/
|
|
443
|
+
const BARE_NONE_ANSWER = /^[-*\s]*\(?\s*(?:none|n\/?a|nothing|no (?:content|entries|response|items|results))\s*\.?\s*\)?\s*$/i;
|
|
444
|
+
export function isBareNoneAnswer(text) {
|
|
445
|
+
return BARE_NONE_ANSWER.test(text.trim());
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Prepended on the ONE retry the empty-section gate triggers. A zero-byte answer is
|
|
449
|
+
* ambiguous — a crashed worker looks exactly like a worker with nothing to say — so
|
|
450
|
+
* the retry's only job is to remove the ambiguity: answer properly, or say "(none)"
|
|
451
|
+
* in as many words.
|
|
452
|
+
*
|
|
453
|
+
* It must NOT turn into an invitation to skip the work: `(none)` is offered only
|
|
454
|
+
* behind an explicit "after you have looked" condition, because this retry also
|
|
455
|
+
* fires on a normal project where the first attempt died for an unrelated reason,
|
|
456
|
+
* and an easy opt-out there would silence real research.
|
|
457
|
+
*
|
|
458
|
+
* MEASUREMENT OPEN. The recovery path's QUALITY on a real repo is being measured
|
|
459
|
+
* (scripts live under /home/edgars/tmp/issue10: first FILES answer faulted to
|
|
460
|
+
* empty, every other child live, against an uninterrupted control). First rep on
|
|
461
|
+
* an earlier wording did NOT take the `(none)` exit but drifted into writing code
|
|
462
|
+
* instead of listing paths — the deliverable-not-inputs failure the base prompt
|
|
463
|
+
* already forbids below this preamble. Blast radius is bounded: the gate fires
|
|
464
|
+
* only on a run that would otherwise have FAILED outright, so a mediocre recovered
|
|
465
|
+
* section is strictly better than the dead task it replaces — but if the drift
|
|
466
|
+
* reproduces, this preamble must restate the section's output contract, not just
|
|
467
|
+
* demand an answer.
|
|
468
|
+
*/
|
|
469
|
+
const EMPTY_SECTION_PREAMBLE = 'STOP. Your previous attempt returned an EMPTY answer — zero characters. An empty '
|
|
470
|
+
+ 'response cannot be accepted, because it is indistinguishable from a worker that '
|
|
471
|
+
+ 'crashed before it wrote anything. Answer again now, and do the research properly '
|
|
472
|
+
+ 'this time: look first, then write what you found, in the required format. Only if '
|
|
473
|
+
+ 'you have looked and there is genuinely nothing to report — the task touches no '
|
|
474
|
+
+ 'existing file, needs no external symbol, or the project has no such tooling — write '
|
|
475
|
+
+ 'exactly `(none)` and nothing else. Do not answer `(none)` to avoid the work, and '
|
|
476
|
+
+ 'never answer with silence.';
|
|
368
477
|
/**
|
|
369
478
|
* Prepended to worker:apis's prompt on the ONE retry the zero-retrieval gate triggers. It
|
|
370
479
|
* names the exact failure (a section written with no retrieval) so the correction is concrete,
|
|
@@ -638,6 +747,35 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
638
747
|
}
|
|
639
748
|
}));
|
|
640
749
|
let r = await runOnce();
|
|
750
|
+
// EMPTY-SECTION GATE (issue #10). A worker that returns zero bytes on a clean run
|
|
751
|
+
// used to fail the whole task ("Research APIS worker produced no output"), which is
|
|
752
|
+
// exactly what an extremely simple task provokes: with nothing on disk to survey and
|
|
753
|
+
// no external symbol in play, silence is the correct answer and the run died on it.
|
|
754
|
+
// Retry ONCE — silence is genuinely ambiguous, and a worker that crashed before
|
|
755
|
+
// writing deserves a second attempt — then accept an explicitly empty section. A
|
|
756
|
+
// provider error behind the silence is classified fatal below and never reaches here.
|
|
757
|
+
let confirmedEmpty = false;
|
|
758
|
+
if (classifyResearchWorker(spec.section, r)?.kind === 'empty') {
|
|
759
|
+
deps.logDebug?.(`${spec.label}: EMPTY answer on a clean exit — retrying once before`
|
|
760
|
+
+ ' accepting the section as having no entries');
|
|
761
|
+
deps.onChildOutput?.(`${spec.label}: empty — retrying`);
|
|
762
|
+
const retry = await runOnce(EMPTY_SECTION_PREAMBLE);
|
|
763
|
+
if (retry.text.trim().length > 0) {
|
|
764
|
+
deps.logDebug?.(`${spec.label}: retry answered (len=${retry.text.trim().length})`
|
|
765
|
+
+ ' — replacing the empty section');
|
|
766
|
+
r = retry;
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
confirmedEmpty = classifyResearchWorker(spec.section, retry)?.kind === 'empty';
|
|
770
|
+
deps.logDebug?.(`${spec.label}: retry STILL empty — `
|
|
771
|
+
+ (confirmedEmpty ?
|
|
772
|
+
'the worker ran twice and reported no entries; recording the'
|
|
773
|
+
+ ' section as empty (NOT a failure)'
|
|
774
|
+
: 'and this attempt did not run cleanly — failing the phase'));
|
|
775
|
+
if (!confirmedEmpty)
|
|
776
|
+
r = retry;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
641
779
|
// ZERO-RETRIEVAL GATE — a deterministic handle, not another instruction. A non-empty
|
|
642
780
|
// section produced with no grounding-retrieval call was written from memory; retry ONCE
|
|
643
781
|
// with a forced retrieval-first pass and keep the retry only if it actually retrieved.
|
|
@@ -675,7 +813,10 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
675
813
|
degradedSectionBody(spec.section, f.reason, res.text)
|
|
676
814
|
: res.text.trim();
|
|
677
815
|
};
|
|
678
|
-
|
|
816
|
+
// `confirmedEmpty` already spent a retry on exactly this ("you wrote nothing"), and
|
|
817
|
+
// the worker answered "nothing applies" a second time — re-asking here would just
|
|
818
|
+
// burn a third child for the same answer.
|
|
819
|
+
if (spec.retryIfSilent && !confirmedEmpty) {
|
|
679
820
|
const body = silentBodyOf(r);
|
|
680
821
|
const verdict = body === null ? null : classifyContextSilence(body);
|
|
681
822
|
if (verdict?.silent && verdict.genuineLoss) {
|
|
@@ -703,12 +844,20 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
703
844
|
const failure = classifyResearchWorker(spec.section, r);
|
|
704
845
|
if (failure?.kind === 'fatal')
|
|
705
846
|
throw failure.error;
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
847
|
+
// A worker that answers "nothing applies" is recorded the same way whether it
|
|
848
|
+
// said so with zero bytes (confirmedEmpty) or with a bare "(none)"/"N/A" — the
|
|
849
|
+
// two are the same answer, and only the marker makes either one distinguishable
|
|
850
|
+
// from a worker that never answered at all.
|
|
851
|
+
const rawText = failure?.kind === 'runaway' ? degradedSectionBody(spec.section, failure.reason, r.text)
|
|
852
|
+
: confirmedEmpty || isBareNoneAnswer(r.text) ? emptySectionBody(spec.section)
|
|
853
|
+
: r.text.trim();
|
|
709
854
|
if (failure?.kind === 'runaway') {
|
|
710
855
|
deps.logDebug?.(`${spec.label}: degraded — ${failure.reason}`);
|
|
711
856
|
}
|
|
857
|
+
if (!confirmedEmpty && isBareNoneAnswer(r.text)) {
|
|
858
|
+
deps.logDebug?.(`${spec.label}: answered "${r.text.trim().slice(0, 40)}" — recording it as`
|
|
859
|
+
+ ' an empty section (the worker ran and found nothing)');
|
|
860
|
+
}
|
|
712
861
|
// Post-check the worker's own output before it is persisted, so the cache a
|
|
713
862
|
// resume reads back is already gated. A degraded partial goes through it too —
|
|
714
863
|
// a truncated section can still carry a laundered claim.
|
|
@@ -86,12 +86,46 @@ 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;
|
|
92
102
|
exitCode: number;
|
|
93
103
|
stderr: string;
|
|
94
104
|
aborted: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The provider-reported cause when the model turn itself failed (disconnect,
|
|
107
|
+
* fetch failed, 5xx after pi's own retries): pi delivers it as an assistant
|
|
108
|
+
* message with stopReason "error" and EMPTY text, exit code 0. Phase children
|
|
109
|
+
* have always surfaced this (child-runner.ts) — research workers did not, so a
|
|
110
|
+
* swallowed provider error reached the caller as an indistinguishable empty
|
|
111
|
+
* answer and was reported as the useless "produced no output" (issue #10).
|
|
112
|
+
* Only meaningful when `text` is empty: a turn that produced text after pi
|
|
113
|
+
* recovered is a success, and the first-error capture must not relabel it.
|
|
114
|
+
*/
|
|
115
|
+
modelError?: string;
|
|
116
|
+
/**
|
|
117
|
+
* Whether the child ever produced a single byte of stdout. Under `--mode json`
|
|
118
|
+
* a live pi child streams protocol events long before any assistant text, so
|
|
119
|
+
* this separates the two ways a worker can come back with nothing:
|
|
120
|
+
* sawOutput true — the child ran and the MODEL chose to write nothing
|
|
121
|
+
* (a legitimately empty section on a trivial task)
|
|
122
|
+
* sawOutput false — the child never spoke at all: it died at startup
|
|
123
|
+
* (unresolvable provider, missing key, bad argv). That is
|
|
124
|
+
* a FAILURE and must never be recorded as "no entries".
|
|
125
|
+
* Derived from the same first-byte timestamp `waitMs`/`workMs` use, so it
|
|
126
|
+
* cannot disagree with them.
|
|
127
|
+
*/
|
|
128
|
+
sawOutput: boolean;
|
|
95
129
|
/**
|
|
96
130
|
* Milliseconds between spawn and the child's first stdout chunk. When
|
|
97
131
|
* multiple workers run concurrently and the upstream model API queues at
|
|
@@ -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.
|
|
@@ -331,7 +365,9 @@ export async function runWorker(input) {
|
|
|
331
365
|
aborted: result.aborted,
|
|
332
366
|
waitMs,
|
|
333
367
|
workMs,
|
|
368
|
+
sawOutput: tFirstByte !== null,
|
|
334
369
|
groundingRetrievalCount,
|
|
370
|
+
...(result.modelError ? { modelError: result.modelError } : {}),
|
|
335
371
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
336
372
|
...(loopHit ? { loopHit } : {}),
|
|
337
373
|
...(timedOut ? { timedOut: true } : {}),
|
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",
|