@mjasnikovs/pi-task 0.39.0 → 0.39.1
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/config/group-args.d.ts +24 -9
- package/dist/config/group-args.js +38 -28
- package/dist/config/register.d.ts +0 -9
- package/dist/config/register.js +17 -62
- package/dist/shared/child-process.d.ts +34 -32
- package/dist/shared/child-process.js +44 -58
- package/dist/shared/command-watchdog.d.ts +12 -4
- package/dist/shared/command-watchdog.js +6 -7
- package/dist/shared/connection-error.d.ts +7 -0
- package/dist/shared/connection-error.js +65 -0
- package/dist/shared/model-endpoint.d.ts +12 -24
- package/dist/shared/model-endpoint.js +32 -82
- package/dist/shared/model-resolve.d.ts +105 -0
- package/dist/shared/model-resolve.js +97 -0
- package/dist/shared/reasoning-capability.d.ts +20 -0
- package/dist/shared/reasoning-capability.js +32 -1
- package/dist/shared/stall-probe.d.ts +51 -0
- package/dist/shared/stall-probe.js +79 -0
- package/dist/task/child-runner.d.ts +76 -278
- package/dist/task/child-runner.js +186 -722
- package/dist/task/context-usage.js +2 -7
- package/dist/task/failure-classifier.js +53 -81
- package/dist/task/gate-child.js +1 -1
- package/dist/task/impl-widget.d.ts +2 -0
- package/dist/task/impl-widget.js +4 -0
- package/dist/task/implementation-hold.d.ts +11 -0
- package/dist/task/implementation-hold.js +20 -0
- package/dist/task/implementation-scope.d.ts +24 -0
- package/dist/task/implementation-scope.js +34 -0
- package/dist/task/loop-detector.d.ts +13 -5
- package/dist/task/loop-detector.js +11 -5
- package/dist/task/model-hold-stash.js +4 -14
- package/dist/task/orchestrator.d.ts +1 -8
- package/dist/task/orchestrator.js +11 -34
- package/dist/task/phases.js +2 -2
- package/dist/task/stall-detector.d.ts +1 -1
- package/dist/task/stall-detector.js +1 -1
- package/dist/workers/model-warning.d.ts +4 -16
- package/dist/workers/model-warning.js +14 -70
- package/dist/workers/pi-worker-core.d.ts +65 -20
- package/dist/workers/pi-worker-core.js +109 -50
- package/dist/workers/reasoning-warning.js +2 -24
- package/dist/workers/worker-failure.d.ts +2 -0
- package/dist/workers/worker-failure.js +2 -1
- package/dist/workers/worker-kill.d.ts +30 -11
- package/dist/workers/worker-kill.js +68 -20
- package/dist/workers/worker-profiles.d.ts +20 -0
- package/dist/workers/worker-profiles.js +22 -9
- package/package.json +1 -1
|
@@ -1,26 +1,15 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
|
-
import {
|
|
3
|
-
import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
|
|
2
|
+
import { setGroupModels, setModelEndpoints } from '../config/group-args.js';
|
|
4
3
|
import { CHILD_GROUPS } from '../config/groups.js';
|
|
5
|
-
import {
|
|
4
|
+
import { resolveGroupModels, resolveModelEndpoints } from '../shared/model-resolve.js';
|
|
6
5
|
import { registerSessionHint } from './session-hint.js';
|
|
7
6
|
const WIDGET_KEY = 'pi-task-model-warning';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const spec =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const parts = splitSpec(spec);
|
|
15
|
-
if (!parts || lookup.find(parts.provider, parts.id) === undefined) {
|
|
16
|
-
out.push({ group, spec, why: 'unresolved' });
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
if (lookup.extensionProviders.has(parts.provider)) {
|
|
20
|
-
out.push({ group, spec, why: 'extension' });
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return out;
|
|
7
|
+
/** The cells worth a line, in group order. */
|
|
8
|
+
export function modelProblems(snapshot) {
|
|
9
|
+
return CHILD_GROUPS.flatMap(group => {
|
|
10
|
+
const { spec, problem } = snapshot[group];
|
|
11
|
+
return problem === undefined ? [] : [{ group, spec, why: problem }];
|
|
12
|
+
});
|
|
24
13
|
}
|
|
25
14
|
/**
|
|
26
15
|
* The hint line, or null when every cell is fine.
|
|
@@ -54,60 +43,15 @@ export function registerModelWarning(pi,
|
|
|
54
43
|
readSpecs = () => getConfig().groupModels) {
|
|
55
44
|
// TWO handlers, deliberately. The resolution pass must run in EVERY mode:
|
|
56
45
|
// `registerSessionHint` returns early when `ctx.mode !== 'tui'`, so folding
|
|
57
|
-
// this into it would leave the argv drop
|
|
58
|
-
// every headless and `--print` run — a
|
|
59
|
-
// watching is not a guard.
|
|
46
|
+
// this into it would leave the argv drop, the churn windows and the
|
|
47
|
+
// dead-backend probe disarmed for every headless and `--print` run — a
|
|
48
|
+
// guard that only works when someone is watching is not a guard.
|
|
60
49
|
pi.on('session_start', (_event, ctx) => {
|
|
61
|
-
|
|
50
|
+
setGroupModels(resolveGroupModels(ctx, readSpecs()));
|
|
51
|
+
setModelEndpoints(resolveModelEndpoints(ctx));
|
|
62
52
|
});
|
|
63
53
|
registerSessionHint(pi, WIDGET_KEY, ctx => {
|
|
64
|
-
const text = formatModelWarning(
|
|
54
|
+
const text = formatModelWarning(modelProblems(resolveGroupModels(ctx, readSpecs())));
|
|
65
55
|
return text === null ? null : { text };
|
|
66
56
|
});
|
|
67
57
|
}
|
|
68
|
-
/**
|
|
69
|
-
* Answer, once, what every model cell resolves to — and leave both answers where
|
|
70
|
-
* the code that has no `ctx` can read them.
|
|
71
|
-
*
|
|
72
|
-
* `unresolved` only feeds the argv drop: an extension-provided model may well
|
|
73
|
-
* work, since children load explicitly whitelisted extensions, and dropping its
|
|
74
|
-
* flag would break a config that is merely fragile.
|
|
75
|
-
*/
|
|
76
|
-
export function resolveModelCells(ctx, specs) {
|
|
77
|
-
const problems = findModelProblems(lookupFor(ctx), specs);
|
|
78
|
-
setUnusableSpecs(problems.filter(p => p.why === 'unresolved').map(p => p.spec));
|
|
79
|
-
// The SAME walk fills the window table, so the argv and the churn rule can
|
|
80
|
-
// never disagree about which model a group runs on.
|
|
81
|
-
//
|
|
82
|
-
// An `inherit` cell gets NO entry, not the parent's window. Storing one
|
|
83
|
-
// would freeze a session_start snapshot in front of the live per-run value,
|
|
84
|
-
// so a user who switches the session model with Ctrl+P to a bigger one would
|
|
85
|
-
// have every child judged against the old window — the churn rule then fires
|
|
86
|
-
// early and kills a healthy child.
|
|
87
|
-
//
|
|
88
|
-
// The whole walk is guarded because `ctx.model` and `ctx.modelRegistry` are
|
|
89
|
-
// GETTERS that call `assertActive()` and throw on a stale context. Losing the
|
|
90
|
-
// windows must not also lose the `setUnusableSpecs` above it, nor the hint.
|
|
91
|
-
try {
|
|
92
|
-
setGroupWindows(Object.fromEntries(CHILD_GROUPS.filter(g => specs[g] !== MODEL_INHERIT).map(g => [g, contextWindowForSpec(ctx, specs[g])])));
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
setGroupWindows({});
|
|
96
|
-
}
|
|
97
|
-
return problems;
|
|
98
|
-
}
|
|
99
|
-
function lookupFor(ctx) {
|
|
100
|
-
try {
|
|
101
|
-
const registry = ctx.modelRegistry;
|
|
102
|
-
return {
|
|
103
|
-
find: (provider, id) => registry.find(provider, id),
|
|
104
|
-
extensionProviders: new Set(registry.getRegisteredProviderIds())
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
// A registry that cannot answer must not condemn every cell. Claiming
|
|
109
|
-
// every spec is unresolved would drop every --model flag on a session
|
|
110
|
-
// whose runtime simply was not ready.
|
|
111
|
-
return { find: () => ({}), extensionProviders: new Set() };
|
|
112
|
-
}
|
|
113
|
-
}
|
|
@@ -3,6 +3,29 @@ import { type CommandKill } from '../shared/command-watchdog.js';
|
|
|
3
3
|
export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
|
|
4
4
|
import { RESTART_ORDER } from './worker-kill.js';
|
|
5
5
|
import { type WorkerGuardOverride, type WorkerGuardPolicy, type WorkerPolicyInputs, type WorkerProfileId } from './worker-profiles.js';
|
|
6
|
+
/**
|
|
7
|
+
* The argv of one model child.
|
|
8
|
+
*
|
|
9
|
+
* `--mode json` puts the child into the structured event stream the runner
|
|
10
|
+
* parses. Without it the child emits plain text, every line fails JSON.parse,
|
|
11
|
+
* finalText stays empty, and every caller fails with "produced no output". A
|
|
12
|
+
* refactor has dropped it once already; do not remove it again.
|
|
13
|
+
*
|
|
14
|
+
* An empty `tools` string means "no tools at all" — `--no-tools`, never
|
|
15
|
+
* `--tools ''`, which pi rejects. A no-tools child cannot make a tool call, so
|
|
16
|
+
* it carries no in-run guard extension either: the guards all hang off pi's
|
|
17
|
+
* `tool_call` hook.
|
|
18
|
+
*
|
|
19
|
+
* The prompt is NOT an argv element: it goes over stdin (getPiInvocation), so a
|
|
20
|
+
* large prompt cannot exceed the OS argv ceiling, which fails the spawn outright
|
|
21
|
+
* rather than truncating (`E2BIG` on this platform).
|
|
22
|
+
*
|
|
23
|
+
* `groupArgs` is the child's group fragment, `--model` then `--thinking`, either
|
|
24
|
+
* half possibly absent. Resolved by the CALLER: both are properties of the
|
|
25
|
+
* child's ROLE, and this function is handed tools, not a name. One field rather
|
|
26
|
+
* than a `model` beside a `thinking` so nothing composes the halves by hand.
|
|
27
|
+
*/
|
|
28
|
+
export declare function childArgs(tools: string, extensions?: readonly string[], groupArgs?: readonly string[]): string[];
|
|
6
29
|
/**
|
|
7
30
|
* Tool calls that can GROUND an APIS claim — i.e. return content a signature or
|
|
8
31
|
* command could be cited from. `pi-worker-docs` (the primary), `read` and `grep`
|
|
@@ -66,10 +89,29 @@ export interface RunWorkerInput {
|
|
|
66
89
|
cwd: string;
|
|
67
90
|
signal?: AbortSignal;
|
|
68
91
|
spawn?: SpawnFn;
|
|
69
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Comma-separated tool whitelist passed to `pi --tools`. Defaults to
|
|
94
|
+
* read,grep,find,ls; `''` means `--no-tools` (see childArgs).
|
|
95
|
+
*/
|
|
70
96
|
tools?: string;
|
|
71
97
|
/** Internal extension entry-point paths to load via `-e <path>` (see childBaseArgs). */
|
|
72
|
-
extensions?: string[];
|
|
98
|
+
extensions?: readonly string[];
|
|
99
|
+
/**
|
|
100
|
+
* ONE more attempt after the loop budget is spent, with different tools and
|
|
101
|
+
* a terminal hint, instead of returning the loop kill. It is not a retry:
|
|
102
|
+
* no restart rule runs on it, and its result is the run's result.
|
|
103
|
+
*
|
|
104
|
+
* For a child whose deliverable is a text rewrite that never strictly
|
|
105
|
+
* needed a read (refine): a model that thrashed re-reading files is stripped
|
|
106
|
+
* of its tools and ordered to emit from what it has, because a hard fail
|
|
107
|
+
* there kills a whole /task-auto run for a model that merely over-explored.
|
|
108
|
+
* A child whose output depends on real reads must NOT carry one — with no
|
|
109
|
+
* tools it would fabricate.
|
|
110
|
+
*/
|
|
111
|
+
rescue?: {
|
|
112
|
+
tools: string;
|
|
113
|
+
hint: (hit: LoopHit) => string;
|
|
114
|
+
};
|
|
73
115
|
/** Called for each tool execution start and text-writing event inside the worker. */
|
|
74
116
|
onLine?: (line: string) => void;
|
|
75
117
|
/** Called when a tool call FINISHES, with its (truncatable) result — lets a caller
|
|
@@ -197,6 +239,13 @@ export interface WorkerRestart {
|
|
|
197
239
|
workMs: number;
|
|
198
240
|
/** Reason-specific diagnosis: the looping call, the hung tool, the error text. */
|
|
199
241
|
detail?: string;
|
|
242
|
+
/** The hit itself on a `loop` restart, for a consumer that records the call. */
|
|
243
|
+
loopHit?: LoopHit;
|
|
244
|
+
/**
|
|
245
|
+
* This attempt was discarded for the RESCUE, not a retry: the loop budget
|
|
246
|
+
* was spent, and what follows runs under `rescue`'s tools and hint.
|
|
247
|
+
*/
|
|
248
|
+
rescue?: true;
|
|
200
249
|
/**
|
|
201
250
|
* Characters of ANSWER TEXT this attempt had produced at the moment it was
|
|
202
251
|
* thrown away.
|
|
@@ -324,10 +373,13 @@ export interface RunWorkerResult {
|
|
|
324
373
|
*
|
|
325
374
|
* Check BEFORE `aborted`, same reasoning as `stalled`: the kill aborts too.
|
|
326
375
|
*/
|
|
327
|
-
commandTimedOut?:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
376
|
+
commandTimedOut?: CommandKill;
|
|
377
|
+
/**
|
|
378
|
+
* The final attempt was the `rescue`. Its text is the run's answer; a
|
|
379
|
+
* rescue that produced none is honestly the loop kill it stood in for, and
|
|
380
|
+
* the caller reports it as one.
|
|
381
|
+
*/
|
|
382
|
+
rescued?: true;
|
|
331
383
|
/**
|
|
332
384
|
* Set when the stream watchdog killed the worker's FINAL attempt: the model
|
|
333
385
|
* stream produced nothing for the configured window while no tool was running.
|
|
@@ -350,9 +402,16 @@ interface RestartState {
|
|
|
350
402
|
streamStalled?: {
|
|
351
403
|
idleMs: number;
|
|
352
404
|
};
|
|
405
|
+
stalled: boolean;
|
|
406
|
+
/** The profile's `stalled.restart` switch. */
|
|
407
|
+
restartOnStalled: boolean;
|
|
353
408
|
timedOut: boolean;
|
|
354
409
|
modelError?: string;
|
|
355
410
|
leaked: string | null;
|
|
411
|
+
/** A clean, complete run that answered with no text at all. */
|
|
412
|
+
empty: boolean;
|
|
413
|
+
/** The profile's `empty-answer` switch. */
|
|
414
|
+
restartOnEmpty: boolean;
|
|
356
415
|
/** The cap this attempt actually died against, not the configured one:
|
|
357
416
|
* `extend`/`progress` can push the deadline out during the attempt. */
|
|
358
417
|
effectiveCapMs: number;
|
|
@@ -402,19 +461,5 @@ interface RestartRule {
|
|
|
402
461
|
/** Backoff before re-spawning, in ms. Only the connection rule waits. */
|
|
403
462
|
backoffMs?: (s: RestartState) => number;
|
|
404
463
|
}
|
|
405
|
-
/**
|
|
406
|
-
* The restart ladder, in precedence order. FIRST MATCH WINS.
|
|
407
|
-
*
|
|
408
|
-
* Read the `!loopHit` guards as "a loop kill outranks me even when it has no
|
|
409
|
-
* budget left". They are not redundant with row order: when a loop is detected
|
|
410
|
-
* but the shared budget is spent, row 1 declines, and without those guards row 2
|
|
411
|
-
* or 4 would then restart the same runaway child under a hint that does not
|
|
412
|
-
* describe why it died.
|
|
413
|
-
*
|
|
414
|
-
* The whole ritual — check the budget, set the hint, spend the counters, record
|
|
415
|
-
* and announce the discarded attempt, sleep, re-spawn — belongs to the loop in
|
|
416
|
-
* `runWorker`, so a new failure mode is one row here and cannot be added without
|
|
417
|
-
* becoming visible in `restarts`.
|
|
418
|
-
*/
|
|
419
464
|
export declare const RESTART_RULES: readonly RestartRule[];
|
|
420
465
|
export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
|
|
@@ -4,9 +4,9 @@ import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../s
|
|
|
4
4
|
export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
|
|
5
5
|
import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
|
|
6
6
|
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
7
|
-
import { LoopDetector, MAX_LOOP_RESTARTS } from '../task/loop-detector.js';
|
|
7
|
+
import { LoopDetector, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/loop-detector.js';
|
|
8
8
|
import { StallDetector, formatStallHint } from '../task/stall-detector.js';
|
|
9
|
-
import {
|
|
9
|
+
import { isConnectionError, connectionRetryBackoffMs } from '../shared/connection-error.js';
|
|
10
10
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
11
11
|
import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
|
|
12
12
|
import { modelSpecFromArgs } from '../config/group-models.js';
|
|
@@ -16,6 +16,33 @@ import { CARRY_FORWARD_IDS, RESTART_ORDER } from './worker-kill.js';
|
|
|
16
16
|
import { applyOverride, WORKER_PROFILES } from './worker-profiles.js';
|
|
17
17
|
/** The tool whitelist a caller gets when it names none. */
|
|
18
18
|
const DEFAULT_TOOLS = 'read,grep,find,ls';
|
|
19
|
+
/**
|
|
20
|
+
* The argv of one model child.
|
|
21
|
+
*
|
|
22
|
+
* `--mode json` puts the child into the structured event stream the runner
|
|
23
|
+
* parses. Without it the child emits plain text, every line fails JSON.parse,
|
|
24
|
+
* finalText stays empty, and every caller fails with "produced no output". A
|
|
25
|
+
* refactor has dropped it once already; do not remove it again.
|
|
26
|
+
*
|
|
27
|
+
* An empty `tools` string means "no tools at all" — `--no-tools`, never
|
|
28
|
+
* `--tools ''`, which pi rejects. A no-tools child cannot make a tool call, so
|
|
29
|
+
* it carries no in-run guard extension either: the guards all hang off pi's
|
|
30
|
+
* `tool_call` hook.
|
|
31
|
+
*
|
|
32
|
+
* The prompt is NOT an argv element: it goes over stdin (getPiInvocation), so a
|
|
33
|
+
* large prompt cannot exceed the OS argv ceiling, which fails the spawn outright
|
|
34
|
+
* rather than truncating (`E2BIG` on this platform).
|
|
35
|
+
*
|
|
36
|
+
* `groupArgs` is the child's group fragment, `--model` then `--thinking`, either
|
|
37
|
+
* half possibly absent. Resolved by the CALLER: both are properties of the
|
|
38
|
+
* child's ROLE, and this function is handed tools, not a name. One field rather
|
|
39
|
+
* than a `model` beside a `thinking` so nothing composes the halves by hand.
|
|
40
|
+
*/
|
|
41
|
+
export function childArgs(tools, extensions = [], groupArgs = []) {
|
|
42
|
+
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
43
|
+
const internal = tools === '' ? [] : extensions;
|
|
44
|
+
return [...childBaseArgs(internal), ...groupArgs, '--mode', 'json', ...toolFlags];
|
|
45
|
+
}
|
|
19
46
|
/**
|
|
20
47
|
* The one place `'unknown'` becomes the 0 both consumers already treat as
|
|
21
48
|
* "no window". Written once so a future reader cannot re-introduce the optional
|
|
@@ -232,21 +259,22 @@ absoluteCeilingMs) {
|
|
|
232
259
|
* `runWorker`, so a new failure mode is one row here and cannot be added without
|
|
233
260
|
* becoming visible in `restarts`.
|
|
234
261
|
*/
|
|
262
|
+
/**
|
|
263
|
+
* A stall hit carries no meaningful windowSize (rule 1 sets it to 0), so
|
|
264
|
+
* printing the loop shape would misname why the attempt died.
|
|
265
|
+
*/
|
|
266
|
+
function loopDetail(hit) {
|
|
267
|
+
return hit.stall ?
|
|
268
|
+
`${hit.call.name} ${hit.stall} ×${hit.count}`
|
|
269
|
+
: `${hit.call.name} ×${hit.count}/${hit.windowSize}`;
|
|
270
|
+
}
|
|
235
271
|
export const RESTART_RULES = [
|
|
236
272
|
{
|
|
237
|
-
// A loop-kill
|
|
238
|
-
// already gets (runPhaseChild) — name the offending call so the
|
|
273
|
+
// A loop-kill is restarted with a hint naming the offending call so the
|
|
239
274
|
// re-spawn avoids it. Bounded by the shared restart budget.
|
|
240
275
|
reason: 'loop',
|
|
241
276
|
detect: s => s.loopHit && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
|
|
242
|
-
{
|
|
243
|
-
// A stall hit carries no meaningful windowSize (rule 1 sets
|
|
244
|
-
// it to 0), so printing the loop shape would misname why the
|
|
245
|
-
// attempt died.
|
|
246
|
-
detail: s.loopHit.stall ?
|
|
247
|
-
`${s.loopHit.call.name} ${s.loopHit.stall} ×${s.loopHit.count}`
|
|
248
|
-
: `${s.loopHit.call.name} ×${s.loopHit.count}/${s.loopHit.windowSize}`
|
|
249
|
-
}
|
|
277
|
+
{ detail: loopDetail(s.loopHit) }
|
|
250
278
|
: null,
|
|
251
279
|
hint: s => s.loopHit.stall ? formatStallHint(s.loopHit.stall) : formatLoopHint(s.loopHit),
|
|
252
280
|
counters: { shared: true }
|
|
@@ -284,6 +312,19 @@ export const RESTART_RULES = [
|
|
|
284
312
|
hint: s => streamStallHint(s.streamStalled.idleMs),
|
|
285
313
|
counters: { shared: true }
|
|
286
314
|
},
|
|
315
|
+
{
|
|
316
|
+
// A dead-backend kill, for the profiles that would rather earn the
|
|
317
|
+
// verdict on every attempt than trust one probe sample. No hint: nothing
|
|
318
|
+
// the model did caused it, and a hint in flight must survive the retry.
|
|
319
|
+
reason: 'stalled',
|
|
320
|
+
detect: s => (s.stalled
|
|
321
|
+
&& !s.loopHit
|
|
322
|
+
&& s.restartOnStalled
|
|
323
|
+
&& s.restartBudgetSpent < MAX_LOOP_RESTARTS) ?
|
|
324
|
+
{ detail: 'no output for the stall window and no endpoint answered' }
|
|
325
|
+
: null,
|
|
326
|
+
counters: { shared: true }
|
|
327
|
+
},
|
|
287
328
|
{
|
|
288
329
|
// A wall-clock timeout (the backstop for varied thrash the exact-match
|
|
289
330
|
// detector misses) is also restartable, sharing the same budget. Skip when
|
|
@@ -332,25 +373,22 @@ export const RESTART_RULES = [
|
|
|
332
373
|
: null,
|
|
333
374
|
hint: s => leakedToolCallHint(s.leaked),
|
|
334
375
|
counters: { leak: true }
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
// An empty completion on a clean run, for the profiles that treat it as
|
|
379
|
+
// a swallowed provider error rather than an answer. No hint: there is
|
|
380
|
+
// nothing to correct, and one already in flight must survive.
|
|
381
|
+
reason: 'empty-answer',
|
|
382
|
+
detect: s => s.empty && !s.loopHit && s.restartOnEmpty && s.restartBudgetSpent < MAX_LOOP_RESTARTS ?
|
|
383
|
+
{ detail: 'no assistant text' }
|
|
384
|
+
: null,
|
|
385
|
+
counters: { shared: true }
|
|
335
386
|
}
|
|
336
387
|
];
|
|
337
388
|
export async function runWorker(input) {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
// shows both halves: under `json` a session subscriber writes every event to
|
|
342
|
-
// stdout as it arrives, while under `text` NOTHING is written until after the
|
|
343
|
-
// prompt resolves, when the last assistant message is printed once. That is
|
|
344
|
-
// what makes the wait/work split real — onFirstByte would otherwise fire
|
|
345
|
-
// moments before close and leave workMs at nearly zero.
|
|
346
|
-
const baseArgs = [
|
|
347
|
-
...childBaseArgs(input.extensions ?? []),
|
|
348
|
-
...(input.groupArgs ?? []),
|
|
349
|
-
'--mode',
|
|
350
|
-
'json',
|
|
351
|
-
'--tools',
|
|
352
|
-
tools
|
|
353
|
-
];
|
|
389
|
+
// Reassigned once at most, by the rescue.
|
|
390
|
+
let tools = input.tools ?? DEFAULT_TOOLS;
|
|
391
|
+
let rescued = false;
|
|
354
392
|
// ONE resolution, before the first attempt. Every guard read below goes
|
|
355
393
|
// through `policy`, so "which knobs is this child running" has exactly one
|
|
356
394
|
// answer and it is observable (`onPolicy`) rather than inferable.
|
|
@@ -404,7 +442,11 @@ export async function runWorker(input) {
|
|
|
404
442
|
const prompt = [hint, carried, input.prompt]
|
|
405
443
|
.filter((p) => p !== null)
|
|
406
444
|
.join('\n\n');
|
|
407
|
-
|
|
445
|
+
// `--mode json` (childArgs) makes pi emit events as they happen instead
|
|
446
|
+
// of buffering the assistant text and flushing on exit, which is what
|
|
447
|
+
// makes the wait/work split below real — onFirstByte would otherwise
|
|
448
|
+
// fire moments before close and leave workMs at nearly zero.
|
|
449
|
+
const invocation = getPiInvocation(childArgs(tools, input.extensions ?? [], input.groupArgs ?? []), prompt);
|
|
408
450
|
const tAttemptStart = Date.now();
|
|
409
451
|
let tFirstByte = null;
|
|
410
452
|
// loop === false turns the guard off entirely (detector is null and no
|
|
@@ -423,11 +465,6 @@ export async function runWorker(input) {
|
|
|
423
465
|
// context event at all, so waiting for one leaves the rule permanently
|
|
424
466
|
// disarmed. The parent knows the window at spawn time.
|
|
425
467
|
stallDetector?.noteContext(contextWindowTokens(input.contextWindow));
|
|
426
|
-
// Capture the hit the detector reports (it also returns it to the unified
|
|
427
|
-
// runner, which kills the child on a hit). Without capturing it here the
|
|
428
|
-
// SIGTERM that kill produces would surface as a bare non-zero exit the
|
|
429
|
-
// caller couldn't distinguish from a crash.
|
|
430
|
-
let loopHit;
|
|
431
468
|
// Reset EACH attempt: on a restart the previous attempt's calls are
|
|
432
469
|
// discarded with its text, so the count must describe only the attempt
|
|
433
470
|
// whose text this call returns.
|
|
@@ -472,11 +509,9 @@ export async function runWorker(input) {
|
|
|
472
509
|
// Loop detector first: it names the offending call and its
|
|
473
510
|
// hint is the more specific one. The stall detector is the
|
|
474
511
|
// backstop for the thrash shapes a 20-call argument window
|
|
475
|
-
// cannot see.
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
loopHit = hit;
|
|
479
|
-
return hit;
|
|
512
|
+
// cannot see. A hit is returned to the runner, which kills
|
|
513
|
+
// the child and reports it as `kill.by === 'loop'`.
|
|
514
|
+
return loopDetector?.record(call) ?? stallDetector?.record(call) ?? null;
|
|
480
515
|
},
|
|
481
516
|
// Output is the other half of "still working": a worker
|
|
482
517
|
// writing its answer is making progress even when it has no
|
|
@@ -517,7 +552,7 @@ export async function runWorker(input) {
|
|
|
517
552
|
const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
|
|
518
553
|
// Record + announce a discarded attempt. Called from every `continue`
|
|
519
554
|
// branch below, so a restart cannot be added without becoming visible.
|
|
520
|
-
const noteRestart = (reason, detail) => {
|
|
555
|
+
const noteRestart = (reason, detail, extra = {}) => {
|
|
521
556
|
const record = {
|
|
522
557
|
attempt: restarts.length + 1,
|
|
523
558
|
reason,
|
|
@@ -525,7 +560,8 @@ export async function runWorker(input) {
|
|
|
525
560
|
waitMs,
|
|
526
561
|
workMs,
|
|
527
562
|
partialChars: text.trim().length,
|
|
528
|
-
...(detail ? { detail } : {})
|
|
563
|
+
...(detail ? { detail } : {}),
|
|
564
|
+
...extra
|
|
529
565
|
};
|
|
530
566
|
restarts.push(record);
|
|
531
567
|
input.onRestart?.(record);
|
|
@@ -545,22 +581,33 @@ export async function runWorker(input) {
|
|
|
545
581
|
};
|
|
546
582
|
const text = result.text ?? '';
|
|
547
583
|
const timedOut = timeout.timedOut();
|
|
548
|
-
const
|
|
549
|
-
const
|
|
584
|
+
const kill = result.kill;
|
|
585
|
+
const loopHit = kill?.by === 'loop' ? kill.hit : undefined;
|
|
586
|
+
const commandKill = kill?.by === 'command-timeout' ? kill : undefined;
|
|
587
|
+
const streamStalled = kill?.by === 'stream-stall' ? { idleMs: kill.idleMs } : undefined;
|
|
588
|
+
const stalled = kill?.by === 'stalled';
|
|
550
589
|
// Only treat output as a leak on a clean, complete run — a non-zero exit
|
|
551
590
|
// or abort yields partial text the caller already handles, and detecting
|
|
552
591
|
// there would just mislabel the real failure.
|
|
553
|
-
const
|
|
592
|
+
const clean = result.exitCode === 0 && !result.aborted;
|
|
593
|
+
const leaked = clean ? detectLeakedToolCall(text) : null;
|
|
554
594
|
// THE RESTART LADDER. Precedence is RESTART_RULES' row order; this loop
|
|
555
595
|
// owns the ritual every rule would otherwise repeat: budget, hint, counters,
|
|
556
596
|
// record-and-announce, backoff, re-spawn.
|
|
597
|
+
//
|
|
598
|
+
// Not run on the rescue attempt, which is final by contract, nor after a
|
|
599
|
+
// cancel: a user who pressed ESC between attempts must not buy a spawn.
|
|
557
600
|
const state = {
|
|
558
601
|
...(loopHit ? { loopHit } : {}),
|
|
559
602
|
...(commandKill ? { commandKill } : {}),
|
|
560
603
|
...(streamStalled ? { streamStalled } : {}),
|
|
604
|
+
stalled,
|
|
605
|
+
restartOnStalled: guards.stalled !== false && guards.stalled.restart,
|
|
561
606
|
timedOut,
|
|
562
607
|
...(result.modelError !== undefined ? { modelError: result.modelError } : {}),
|
|
563
608
|
leaked,
|
|
609
|
+
empty: clean && result.modelError === undefined && text.trim().length === 0,
|
|
610
|
+
restartOnEmpty: guards['empty-answer'],
|
|
564
611
|
effectiveCapMs,
|
|
565
612
|
tools,
|
|
566
613
|
restartBudgetSpent,
|
|
@@ -568,8 +615,9 @@ export async function runWorker(input) {
|
|
|
568
615
|
connectionRetries: guards['connection-error'],
|
|
569
616
|
leakRetries
|
|
570
617
|
};
|
|
618
|
+
const ladderOpen = !rescued && input.signal?.aborted !== true;
|
|
571
619
|
let restarted = false;
|
|
572
|
-
for (const rule of RESTART_RULES) {
|
|
620
|
+
for (const rule of ladderOpen ? RESTART_RULES : []) {
|
|
573
621
|
const hit = rule.detect(state);
|
|
574
622
|
if (!hit)
|
|
575
623
|
continue;
|
|
@@ -585,7 +633,7 @@ export async function runWorker(input) {
|
|
|
585
633
|
connRetries++;
|
|
586
634
|
// Noted BEFORE any backoff sleep, so the record's wallMs stays the
|
|
587
635
|
// attempt's own clock; the sleep lands in totalWallMs, where it belongs.
|
|
588
|
-
noteRestart(rule.reason, hit.detail);
|
|
636
|
+
noteRestart(rule.reason, hit.detail, loopHit ? { loopHit } : {});
|
|
589
637
|
if (rule.backoffMs)
|
|
590
638
|
await (input.sleepFor ?? defaultSleep)(rule.backoffMs(state));
|
|
591
639
|
restarted = true;
|
|
@@ -593,6 +641,15 @@ export async function runWorker(input) {
|
|
|
593
641
|
}
|
|
594
642
|
if (restarted)
|
|
595
643
|
continue;
|
|
644
|
+
// Reached only with the loop rule out of budget: the rules above all
|
|
645
|
+
// decline a loop-killed attempt once the shared budget is spent.
|
|
646
|
+
if (ladderOpen && loopHit && input.rescue) {
|
|
647
|
+
rescued = true;
|
|
648
|
+
hint = input.rescue.hint(loopHit);
|
|
649
|
+
tools = input.rescue.tools;
|
|
650
|
+
noteRestart('loop', loopDetail(loopHit), { loopHit, rescue: true });
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
596
653
|
// SALVAGE. Returning the LAST attempt's text unconditionally makes a
|
|
597
654
|
// worker whose final attempt was killed early report nothing at all — even
|
|
598
655
|
// when a discarded attempt produced a usable answer that was still in hand
|
|
@@ -617,7 +674,7 @@ export async function runWorker(input) {
|
|
|
617
674
|
exitCode: result.exitCode,
|
|
618
675
|
aborted: result.aborted,
|
|
619
676
|
timedOut,
|
|
620
|
-
...(
|
|
677
|
+
...(stalled ? { stalled: true } : {}),
|
|
621
678
|
...(loopHit ? { loopHit } : {}),
|
|
622
679
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
623
680
|
...(commandKill ? { commandTimedOut: commandKill } : {}),
|
|
@@ -646,16 +703,18 @@ export async function runWorker(input) {
|
|
|
646
703
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
647
704
|
...(loopHit ? { loopHit } : {}),
|
|
648
705
|
...(timedOut ? { timedOut: true } : {}),
|
|
649
|
-
...(
|
|
706
|
+
...(stalled ? { stalled: true } : {}),
|
|
650
707
|
...(streamStalled ? { streamStalled } : {}),
|
|
651
708
|
...(commandKill ?
|
|
652
709
|
{
|
|
653
710
|
commandTimedOut: {
|
|
654
711
|
toolName: commandKill.toolName,
|
|
655
|
-
timeoutMs: commandKill.timeoutMs
|
|
712
|
+
timeoutMs: commandKill.timeoutMs,
|
|
713
|
+
...(commandKill.detail ? { detail: commandKill.detail } : {})
|
|
656
714
|
}
|
|
657
715
|
}
|
|
658
|
-
: {})
|
|
716
|
+
: {}),
|
|
717
|
+
...(rescued ? { rescued: true } : {})
|
|
659
718
|
};
|
|
660
719
|
}
|
|
661
720
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { getConfig } from '../config/config.js';
|
|
23
23
|
import { effectiveReasoning } from '../config/reasoning.js';
|
|
24
24
|
import { reasoningMismatches } from '../shared/reasoning-capability.js';
|
|
25
|
-
import {
|
|
25
|
+
import { resolveModel } from '../shared/model-resolve.js';
|
|
26
26
|
import { probeChatTemplateCaps } from '../shared/model-endpoint.js';
|
|
27
27
|
import { registerSessionHint } from './session-hint.js';
|
|
28
28
|
const WIDGET_KEY = 'pi-task-reasoning-warning';
|
|
@@ -93,7 +93,7 @@ probe = probeChatTemplateCaps,
|
|
|
93
93
|
*/
|
|
94
94
|
readSpecs = () => getConfig().groupModels) {
|
|
95
95
|
registerSessionHint(pi, WIDGET_KEY, ctx => {
|
|
96
|
-
const facts = (g) =>
|
|
96
|
+
const facts = (g) => resolveModel(ctx, readSpecs()[g]);
|
|
97
97
|
const mismatches = reasoningMismatches(facts, readSettings());
|
|
98
98
|
const base = formatReasoningWarning(mismatches);
|
|
99
99
|
if (base === null)
|
|
@@ -123,28 +123,6 @@ readSpecs = () => getConfig().groupModels) {
|
|
|
123
123
|
};
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
|
-
/** What one group runs on, as {@link reasoningMismatches} wants it. */
|
|
127
|
-
function groupModelFacts(ctx, spec) {
|
|
128
|
-
// `inherit` is the session's model. That is decision 3 of the model table —
|
|
129
|
-
// children are NOT switched to follow the host — and the honest value is
|
|
130
|
-
// settings.json's default, which need not be the session's. Naming the
|
|
131
|
-
// session's model is still the better of the two: it is the one the user can
|
|
132
|
-
// see, and on every machine with one provider the two agree.
|
|
133
|
-
const model = spec === MODEL_INHERIT ?
|
|
134
|
-
ctx.model
|
|
135
|
-
: (() => {
|
|
136
|
-
const parts = splitSpec(spec);
|
|
137
|
-
return parts ? ctx.modelRegistry.find(parts.provider, parts.id) : undefined;
|
|
138
|
-
})();
|
|
139
|
-
if (!model)
|
|
140
|
-
return undefined;
|
|
141
|
-
return {
|
|
142
|
-
name: model.name || model.id,
|
|
143
|
-
reasoning: model.reasoning,
|
|
144
|
-
...(model.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: model.thinkingLevelMap }),
|
|
145
|
-
...(model.baseUrl ? { baseUrl: model.baseUrl } : {})
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
126
|
/** The distinct servers behind a set of mismatches, deduped by URL. */
|
|
149
127
|
function distinctBackends(mismatches, facts) {
|
|
150
128
|
const byUrl = new Map();
|
|
@@ -44,6 +44,7 @@ export interface WorkerFailureInput {
|
|
|
44
44
|
commandTimedOut?: {
|
|
45
45
|
toolName: string;
|
|
46
46
|
timeoutMs: number;
|
|
47
|
+
detail?: string;
|
|
47
48
|
};
|
|
48
49
|
streamStalled?: {
|
|
49
50
|
idleMs: number;
|
|
@@ -64,6 +65,7 @@ export type WorkerFailure = {
|
|
|
64
65
|
kind: 'command-timeout';
|
|
65
66
|
toolName: string;
|
|
66
67
|
timeoutMs: number;
|
|
68
|
+
detail?: string;
|
|
67
69
|
} | {
|
|
68
70
|
kind: 'stream-stall';
|
|
69
71
|
idleMs: number;
|
|
@@ -59,7 +59,8 @@ export const FAILURE_RULES = [
|
|
|
59
59
|
{
|
|
60
60
|
kind: 'command-timeout',
|
|
61
61
|
toolName: r.commandTimedOut.toolName,
|
|
62
|
-
timeoutMs: r.commandTimedOut.timeoutMs
|
|
62
|
+
timeoutMs: r.commandTimedOut.timeoutMs,
|
|
63
|
+
...(r.commandTimedOut.detail ? { detail: r.commandTimedOut.detail } : {})
|
|
63
64
|
}
|
|
64
65
|
: null
|
|
65
66
|
},
|