@mjasnikovs/pi-task 0.38.30 → 0.38.32
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/README.md +2 -1
- package/dist/config/config.d.ts +16 -2
- package/dist/config/config.js +7 -2
- package/dist/config/group-args.d.ts +52 -0
- package/dist/config/group-args.js +110 -0
- package/dist/config/group-models.d.ts +88 -0
- package/dist/config/group-models.js +117 -0
- package/dist/config/groups.d.ts +76 -0
- package/dist/config/groups.js +110 -0
- package/dist/config/option-picker.d.ts +42 -0
- package/dist/config/option-picker.js +73 -0
- package/dist/config/reasoning.d.ts +22 -63
- package/dist/config/reasoning.js +37 -108
- package/dist/config/register.d.ts +98 -12
- package/dist/config/register.js +228 -23
- package/dist/index.js +4 -0
- package/dist/remote/push.js +1 -7
- package/dist/shared/command-watchdog.d.ts +63 -0
- package/dist/shared/command-watchdog.js +87 -0
- package/dist/shared/data-home.d.ts +8 -0
- package/dist/shared/data-home.js +14 -0
- package/dist/shared/model-endpoint.d.ts +53 -0
- package/dist/shared/model-endpoint.js +98 -2
- package/dist/shared/reasoning-capability.d.ts +25 -5
- package/dist/shared/reasoning-capability.js +18 -9
- package/dist/task/auto-orchestrator.js +14 -3
- package/dist/task/child-runner.d.ts +92 -15
- package/dist/task/child-runner.js +303 -66
- package/dist/task/context-usage.d.ts +46 -0
- package/dist/task/context-usage.js +41 -0
- package/dist/task/failure-classifier.js +24 -1
- package/dist/task/gate-child.d.ts +15 -4
- package/dist/task/gate-child.js +2 -2
- package/dist/task/gate-deps.js +7 -2
- package/dist/task/implementation-guards.d.ts +26 -0
- package/dist/task/implementation-guards.js +177 -0
- package/dist/task/implementation-hold.d.ts +118 -0
- package/dist/task/implementation-hold.js +165 -0
- package/dist/task/implementation-turn.d.ts +5 -0
- package/dist/task/implementation-turn.js +12 -1
- package/dist/task/loop-detector.d.ts +18 -0
- package/dist/task/loop-detector.js +22 -2
- package/dist/task/model-hold-stash.d.ts +43 -0
- package/dist/task/model-hold-stash.js +70 -0
- package/dist/task/orchestrator.d.ts +18 -5
- package/dist/task/orchestrator.js +63 -6
- package/dist/task/phases.js +18 -5
- package/dist/task/research-worker.d.ts +2 -2
- package/dist/task/research-worker.js +1 -1
- package/dist/workers/docs-core.js +2 -2
- package/dist/workers/docs-lookup.d.ts +4 -3
- package/dist/workers/docs-lookup.js +1 -1
- package/dist/workers/fetch-core.js +2 -2
- package/dist/workers/focused-extractor.d.ts +6 -4
- package/dist/workers/focused-extractor.js +17 -5
- package/dist/workers/index.js +2 -0
- package/dist/workers/model-warning.d.ts +69 -0
- package/dist/workers/model-warning.js +113 -0
- package/dist/workers/pi-worker-core.d.ts +9 -38
- package/dist/workers/pi-worker-core.js +8 -86
- package/dist/workers/pi-worker-docs.js +2 -2
- package/dist/workers/pi-worker.js +4 -4
- package/dist/workers/reasoning-warning.d.ts +17 -9
- package/dist/workers/reasoning-warning.js +69 -22
- package/dist/workers/single-read-guard.d.ts +6 -6
- package/dist/workers/single-read-guard.js +8 -8
- package/dist/workers/worker-profiles.d.ts +11 -3
- package/dist/workers/worker-profiles.js +33 -1
- package/package.json +1 -1
- package/dist/config/reasoning-args.d.ts +0 -23
- package/dist/config/reasoning-args.js +0 -28
- package/dist/task/implementation-thinking.d.ts +0 -56
- package/dist/task/implementation-thinking.js +0 -32
|
@@ -9,21 +9,21 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import { getPiInvocation } from '../shared/pi-invocation.js';
|
|
10
10
|
import { runChild as runChildUnified } from '../shared/child-process.js';
|
|
11
11
|
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
12
|
-
import { LoopDetector } from './loop-detector.js';
|
|
12
|
+
import { LoopDetector, MAX_LOOP_RESTARTS } from './loop-detector.js';
|
|
13
13
|
import { StallDetector, formatStallHint } from './stall-detector.js';
|
|
14
14
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
15
15
|
import { readSection, setTaskSection } from './task-io.js';
|
|
16
16
|
import { streamStallCause } from '../shared/stream-watchdog.js';
|
|
17
|
+
import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../shared/command-watchdog.js';
|
|
18
|
+
import { childModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
|
|
19
|
+
import { modelSpecFromArgs } from '../config/group-models.js';
|
|
20
|
+
// VALUE import, and it is only safe because worker-profiles.ts reads its loop
|
|
21
|
+
// constants from loop-detector.ts. Point those back at this file and the graph
|
|
22
|
+
// closes into a TDZ ReferenceError that no compile step catches.
|
|
23
|
+
import { workerPolicy } from '../workers/worker-profiles.js';
|
|
17
24
|
import { getConfig } from '../config/config.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
21
|
-
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
22
|
-
// phases.ts → child-runner.ts → phases.ts
|
|
23
|
-
export const LOOP_WINDOW = 20;
|
|
24
|
-
export const LOOP_THRESHOLD = 5;
|
|
25
|
-
export const MAX_LOOP_RESTARTS = 2; // 3 strikes total (initial attempt + 2 restarts)
|
|
26
|
-
// MAX_LEAK_RETRIES lives in shared/leaked-tool-call.ts (imported above).
|
|
25
|
+
import { groupChildArgs, groupWindow } from '../config/group-args.js';
|
|
26
|
+
import { groupForChild } from '../config/groups.js';
|
|
27
27
|
// ─── Phase-child wall-clock cap ──────────────────────────────────────────────
|
|
28
28
|
/**
|
|
29
29
|
* Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
|
|
@@ -105,6 +105,75 @@ export class PhaseTimeoutError extends Error {
|
|
|
105
105
|
this.name = 'PhaseTimeoutError';
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The terminal error for a guard kill, or null when the child was not killed.
|
|
110
|
+
*
|
|
111
|
+
* Both spawn paths must ask. A kill reports `exitCode: 0` (child-process.ts uses
|
|
112
|
+
* `code ?? 0`, and a signal gives null), so a path that tests the exit code
|
|
113
|
+
* instead returns the truncated text as the phase's answer.
|
|
114
|
+
*/
|
|
115
|
+
export function guardKillError(name, r, opts = {}) {
|
|
116
|
+
if (r.commandKill)
|
|
117
|
+
return new CommandTimeoutError(name, r.commandKill);
|
|
118
|
+
// A dead-backend verdict is only trusted once every attempt has produced it.
|
|
119
|
+
// The probe now asks about this child's own endpoint rather than ORing over
|
|
120
|
+
// every provider, so it is exact — but it is still one network call at one
|
|
121
|
+
// instant, and a blip is indistinguishable from a death in a single sample.
|
|
122
|
+
// The asymmetry settles it: a backend that really is down costs three 5s
|
|
123
|
+
// probes, a wrong verdict costs the whole run.
|
|
124
|
+
if (r.stalled)
|
|
125
|
+
return opts.finalAttempt === false ? null : new BackendDownError(name);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The dead-backend probe killed a phase child on its LAST attempt.
|
|
130
|
+
*
|
|
131
|
+
* Reaching this means every attempt found no endpoint answering, not one. The
|
|
132
|
+
* single-probe verdict is not trusted on its own, because one sample cannot tell
|
|
133
|
+
* a dead server from a blip. Three failed probes cost ~15s; one wrong verdict
|
|
134
|
+
* costs the run.
|
|
135
|
+
*/
|
|
136
|
+
export class BackendDownError extends Error {
|
|
137
|
+
childName;
|
|
138
|
+
constructor(childName) {
|
|
139
|
+
super(`${childName} child killed: no output for the stall window and the model `
|
|
140
|
+
+ `endpoint did not answer a probe`);
|
|
141
|
+
this.childName = childName;
|
|
142
|
+
this.name = 'BackendDownError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A phase child spent every attempt on a command that never returned. Its own
|
|
147
|
+
* class because the fix is in the SPEC, not the model's exploration: a VERIFY
|
|
148
|
+
* block naming an unbounded `dev` command re-hangs every attempt.
|
|
149
|
+
*/
|
|
150
|
+
export class CommandTimeoutError extends Error {
|
|
151
|
+
childName;
|
|
152
|
+
kill;
|
|
153
|
+
constructor(childName, kill) {
|
|
154
|
+
super(`${childName} child ran \`${kill.toolName}\``
|
|
155
|
+
+ `${kill.detail ? ` (${kill.detail})` : ''} past its `
|
|
156
|
+
+ `${Math.round(kill.timeoutMs / 1000)}s ceiling on every attempt`);
|
|
157
|
+
this.childName = childName;
|
|
158
|
+
this.kill = kill;
|
|
159
|
+
this.name = 'CommandTimeoutError';
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Causes a best-effort `catch` must NOT absorb.
|
|
164
|
+
*
|
|
165
|
+
* A phase child that merely answered badly should degrade — that is what those
|
|
166
|
+
* catches are for. These two are different in kind: the run is over either way,
|
|
167
|
+
* and swallowing them ships a half-built spec while every later phase dies
|
|
168
|
+
* against the same dead backend, or turns a user's ESC into silent progress.
|
|
169
|
+
* `failure-classifier.ts` has a verdict for both; a catch that eats them makes it
|
|
170
|
+
* unreachable.
|
|
171
|
+
*/
|
|
172
|
+
export function isFatalChildCause(e) {
|
|
173
|
+
if (e instanceof BackendDownError)
|
|
174
|
+
return true;
|
|
175
|
+
return e instanceof Error && e.message === USER_CANCELLED;
|
|
176
|
+
}
|
|
108
177
|
// ─── Connection-error retry ──────────────────────────────────────────────────
|
|
109
178
|
/**
|
|
110
179
|
* A connection-class model error is transient: a single dropped fetch to a live
|
|
@@ -120,9 +189,54 @@ export class PhaseTimeoutError extends Error {
|
|
|
120
189
|
* provider 5xx that names a real fault) still fails fast: re-spawning against
|
|
121
190
|
* the same request won't fix it, so burning the budget only delays the report.
|
|
122
191
|
*/
|
|
123
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Transport-level failures worth another attempt.
|
|
194
|
+
*
|
|
195
|
+
* SCOPE, and it is deliberate: connection classes only. pi's own
|
|
196
|
+
* `isRetryableAssistantError` (@earendil-works/pi-ai, `dist/utils/retry.js`) also
|
|
197
|
+
* retries the provider-LOAD family — `429`, `5xx`, `rate limit`, `overloaded` —
|
|
198
|
+
* which `does NOT match real, non-transient faults` in child-runner.test.ts
|
|
199
|
+
* explicitly rejects. That disagreement is real and OPEN; it is not settled here,
|
|
200
|
+
* because this backoff starts at 500ms and a 429 answered that fast is a retry
|
|
201
|
+
* storm, not a recovery.
|
|
202
|
+
*
|
|
203
|
+
* MEASURED against pi before widening: the transport entries added here — a bare
|
|
204
|
+
* `timed out`, `getaddrinfo ENOTFOUND`, `upstream connect`, `reset before
|
|
205
|
+
* headers`, a truncated Anthropic stream and a closed websocket — were all
|
|
206
|
+
* MISSES. Every one is a REMOTE-provider failure, which is why a local llama.cpp
|
|
207
|
+
* setup never surfaced the gap. The errno spellings are pi-task's own: a child
|
|
208
|
+
* reports them through stderr, and pi never sees them.
|
|
209
|
+
*
|
|
210
|
+
* pi's bare `timeout` is deliberately NOT reproduced. It matched a provider 400
|
|
211
|
+
* that merely echoed a `timeout` field back, turning a fail-fast into a full
|
|
212
|
+
* retry budget, and it caught nothing the `timed out` spellings above miss.
|
|
213
|
+
*/
|
|
214
|
+
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|socket connection was closed|fetch failed|network (?:error|timeout)|premature close|terminated|unreachable|getaddrinfo|enotfound|upstream.?connect|reset before headers|timed? out|ended without|stream ended before message_stop|websocket.?(?:closed|error))\b/i;
|
|
215
|
+
/**
|
|
216
|
+
* Provider LOAD, which is transient in a different way: the server is up and
|
|
217
|
+
* saying "not now". pi retries all of these; 53f0488 did not, but its own message
|
|
218
|
+
* names only "context overflow, bad request, auth" as the fail-fast set — a
|
|
219
|
+
* throttle was never argued for, it just rode along in a list written for a LOCAL
|
|
220
|
+
* server, where none of these can occur.
|
|
221
|
+
*
|
|
222
|
+
* Words carry no trailing \b (`overloaded_error` joins on `_`, which is a word
|
|
223
|
+
* character); the bare status codes carry one, or `500` matches inside `15000`.
|
|
224
|
+
*/
|
|
225
|
+
const PROVIDER_LOAD_RE = /(?:overloaded|rate.?limit|too many requests|service.?unavailable|server.?error|internal.?error|provider.?returned.?error)|\b(?:429|500|502|503|504|524)\b/i;
|
|
226
|
+
/**
|
|
227
|
+
* Account facts, not liveness: a budget does not refill on a retry. Checked FIRST,
|
|
228
|
+
* because these arrive worded as a throttle — `429 GoUsageLimitError` is a
|
|
229
|
+
* subscription limit, not a queue.
|
|
230
|
+
*/
|
|
231
|
+
const NON_RETRYABLE_RE = /\b(?:insufficient_quota|quota exceeded|out of budget|billing|usage limit reached|available balance|GoUsageLimitError|FreeUsageLimitError)\b/i;
|
|
232
|
+
/**
|
|
233
|
+
* Retry budget is three attempts at 500ms/1s/2s — three requests over 3.5s, which
|
|
234
|
+
* is not a storm even against a throttle. pi's own ladder is three at 2s/4s/8s.
|
|
235
|
+
*/
|
|
124
236
|
export function isConnectionError(cause) {
|
|
125
|
-
|
|
237
|
+
if (NON_RETRYABLE_RE.test(cause))
|
|
238
|
+
return false;
|
|
239
|
+
return CONNECTION_ERROR_RE.test(cause) || PROVIDER_LOAD_RE.test(cause);
|
|
126
240
|
}
|
|
127
241
|
/** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
|
|
128
242
|
* brief saturation window can drain before we re-issue the request. */
|
|
@@ -133,12 +247,16 @@ const defaultSleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
133
247
|
// ─── Spawn helpers ───────────────────────────────────────────────────────────
|
|
134
248
|
export function childArgs(tools, extensions = [],
|
|
135
249
|
/**
|
|
136
|
-
*
|
|
137
|
-
* Resolved by the CALLER, never here
|
|
138
|
-
* ROLE, and this function is handed tools and extensions, not
|
|
139
|
-
* Omitted ⇒ byte-identical argv to the version before
|
|
250
|
+
* This child's group fragment: `--model` then `--thinking`, either half
|
|
251
|
+
* possibly absent. Resolved by the CALLER, never here — both are properties
|
|
252
|
+
* of the child's ROLE, and this function is handed tools and extensions, not
|
|
253
|
+
* a name. Omitted ⇒ byte-identical argv to the version before group profiles.
|
|
254
|
+
*
|
|
255
|
+
* ONE field rather than a `model` beside a `thinking`, because nothing may
|
|
256
|
+
* compose the two halves by hand: `groupChildArgs` is the only producer, so a
|
|
257
|
+
* doubled `--thinking` is unreachable rather than merely unlikely.
|
|
140
258
|
*/
|
|
141
|
-
|
|
259
|
+
groupArgs = []) {
|
|
142
260
|
// `--mode json` puts the child into the structured event stream the
|
|
143
261
|
// unified runner parses in `mode: 'json-events'`. Without it the child
|
|
144
262
|
// emits plain text, every line fails JSON.parse, finalText stays empty,
|
|
@@ -160,35 +278,80 @@ thinking = []) {
|
|
|
160
278
|
// one — the guards all hang off pi's `tool_call` hook.
|
|
161
279
|
const toolFlags = tools === '' ? ['--no-tools'] : ['--tools', tools];
|
|
162
280
|
const internal = tools === '' ? [] : extensions;
|
|
163
|
-
return [...childBaseArgs(internal), ...
|
|
281
|
+
return [...childBaseArgs(internal), ...groupArgs, '--mode', 'json', ...toolFlags];
|
|
164
282
|
}
|
|
165
283
|
// Sentinel error thrown when the user dismisses a grill-me dialog.
|
|
166
284
|
// Defined here (not in failure-classifier.ts) to avoid circular dependency.
|
|
167
285
|
export const USER_CANCELLED = '__user_cancelled__';
|
|
168
|
-
|
|
169
|
-
|
|
286
|
+
/**
|
|
287
|
+
* The `phase` row of WORKER_PROFILES, resolved with this machine's config.
|
|
288
|
+
*
|
|
289
|
+
* Read here rather than at module load so a /task-config change reaches the next
|
|
290
|
+
* child, the same contract childBaseArgs already keeps. Both spawn paths in this
|
|
291
|
+
* file go through it, so the degraded final attempt cannot drift from the ordinary
|
|
292
|
+
* one — the mislabel class runDegradedFinalAttempt's own comment warns about.
|
|
293
|
+
*/
|
|
294
|
+
export function phasePolicy() {
|
|
295
|
+
return workerPolicy('phase', {
|
|
296
|
+
commandTimeoutMs: getConfig().requestTimeoutMs,
|
|
297
|
+
streamInactivityMs: getConfig().streamInactivityMs
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, groupArgs, commandCeilingMs }) {
|
|
301
|
+
const invocation = getPiInvocation(childArgs(tools, extensions, groupArgs), prompt);
|
|
170
302
|
let loopHit;
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
303
|
+
const guards = phasePolicy().guards;
|
|
304
|
+
// Null when the user set the ceiling to `off`. Why a phase child needs this at
|
|
305
|
+
// all is the `phase` row's `why` in worker-profiles.ts.
|
|
306
|
+
const cmdWatch = commandWatch(commandCeilingMs ?? guards['command-timeout']);
|
|
307
|
+
const childSignal = cmdWatch ? AbortSignal.any([signal, cmdWatch.signal]) : signal;
|
|
308
|
+
let result;
|
|
309
|
+
try {
|
|
310
|
+
result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, childSignal, {
|
|
311
|
+
mode: 'json-events',
|
|
312
|
+
// A hung model stream reports nothing at all, so without this the
|
|
313
|
+
// phase child waits forever. The kill
|
|
314
|
+
// is reported below as a connection-class cause, which routes it into
|
|
315
|
+
// the retry/backoff path this file already has for a LOUD disconnect.
|
|
316
|
+
streamInactivityMs: guards['stream-stall'],
|
|
317
|
+
...(guards.stalled === false ?
|
|
318
|
+
{}
|
|
319
|
+
: {
|
|
320
|
+
stall: {
|
|
321
|
+
afterMs: guards.stalled.afterMs,
|
|
322
|
+
probe: guards.stalled.probe
|
|
323
|
+
?? (() => probeModelEndpoints(childModelEndpoints(modelSpecFromArgs(groupArgs ?? []))))
|
|
324
|
+
}
|
|
325
|
+
}),
|
|
326
|
+
onLine,
|
|
327
|
+
onContextUsage,
|
|
328
|
+
...(contextWindow && contextWindow > 0 ? { contextWindow } : {}),
|
|
329
|
+
// ALWAYS wired, never conditional on the caller wanting results:
|
|
330
|
+
// the sink emits a tool-execution-end only when a handler exists,
|
|
331
|
+
// and without that end the command watchdog's timer is never
|
|
332
|
+
// disarmed — every healthy tool call would then look hung.
|
|
333
|
+
onToolResult: r => {
|
|
334
|
+
cmdWatch?.onEnd(r.toolCallId);
|
|
335
|
+
onToolResult?.(r.text, r.isError);
|
|
336
|
+
},
|
|
337
|
+
onToolCall: call => {
|
|
338
|
+
// Before the detectors: a call they let through still needs its
|
|
339
|
+
// clock started.
|
|
340
|
+
cmdWatch?.onStart(call);
|
|
341
|
+
if (!onToolCall)
|
|
342
|
+
return null;
|
|
343
|
+
const hit = onToolCall(call);
|
|
344
|
+
if (hit && !loopHit) {
|
|
345
|
+
loopHit = hit;
|
|
346
|
+
}
|
|
347
|
+
return hit; // propagate to unified runner so it can kill
|
|
188
348
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
cmdWatch?.clear();
|
|
353
|
+
}
|
|
354
|
+
const commandKill = cmdWatch?.killed();
|
|
192
355
|
// Use `||` (not `??`) so an empty string from json-events mode falls
|
|
193
356
|
// back to raw stdout. Without this, a child that exits 0 but emits no
|
|
194
357
|
// assistant text (e.g. model API error swallowed in json mode) always
|
|
@@ -202,14 +365,16 @@ export async function runChild({ cwd, tools, prompt, signal, onLine, onContextUs
|
|
|
202
365
|
?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
|
|
203
366
|
return {
|
|
204
367
|
text,
|
|
205
|
-
// WE killed this child, so its exit status
|
|
206
|
-
// the
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
exitCode: result.streamStalled ? 0 : result.exitCode,
|
|
368
|
+
// WE killed this child, so its exit status is our own SIGTERM. Report 0
|
|
369
|
+
// and let the named cause carry it. EVERY guard kill must be listed: one
|
|
370
|
+
// omitted here arrives as exit 0 with partial text, and triageChildResult
|
|
371
|
+
// returns it as a successful answer.
|
|
372
|
+
exitCode: result.streamStalled || result.stalled || commandKill ? 0 : result.exitCode,
|
|
210
373
|
stderr: result.stderr.trim(),
|
|
211
374
|
loopHit,
|
|
212
375
|
modelError,
|
|
376
|
+
...(commandKill ? { commandKill } : {}),
|
|
377
|
+
...(result.stalled ? { stalled: true } : {}),
|
|
213
378
|
// A tool call the model wrote as text (wrong dialect) never executed and
|
|
214
379
|
// sailed past the structured-event guards above; flag it so the wrappers
|
|
215
380
|
// can re-prompt instead of accepting the unexecuted call. Only meaningful
|
|
@@ -301,17 +466,17 @@ async function triageChildResult(deps, name, r, attempt, budget, verb) {
|
|
|
301
466
|
* exit status describes our SIGTERM and says nothing about its verdict.
|
|
302
467
|
*/
|
|
303
468
|
/**
|
|
304
|
-
* The
|
|
469
|
+
* The group fragment for a named child, or `[]` when the name is unmapped.
|
|
305
470
|
*
|
|
306
471
|
* An unmapped name INHERITS rather than throwing: a child that reaches the model
|
|
307
472
|
* with today's argv is always safe, and aborting a user's task over a missing
|
|
308
473
|
* table row would be a worse failure than the one it reports. The guard that
|
|
309
|
-
* makes the table complete is `
|
|
474
|
+
* makes the table complete is `config/groups.test.ts`, which fails the BUILD —
|
|
310
475
|
* where someone can actually fix it.
|
|
311
476
|
*/
|
|
312
|
-
export function
|
|
313
|
-
const group =
|
|
314
|
-
return group ?
|
|
477
|
+
export function groupArgsForChild(name) {
|
|
478
|
+
const group = groupForChild(name);
|
|
479
|
+
return group ? groupChildArgs(group) : [];
|
|
315
480
|
}
|
|
316
481
|
/**
|
|
317
482
|
* What a PHASE child's invocation carries, said once.
|
|
@@ -322,14 +487,29 @@ export function thinkingForChild(name) {
|
|
|
322
487
|
* which is what the degrade's own comment ("the degrade changes the TOOLS, not
|
|
323
488
|
* the role") claimed while three bare `undefined`s quietly made it false.
|
|
324
489
|
*/
|
|
325
|
-
|
|
490
|
+
/**
|
|
491
|
+
* The context window a child of this group runs against.
|
|
492
|
+
*
|
|
493
|
+
* The GROUP's window when this session resolved one, else the run's. Too small a
|
|
494
|
+
* window makes the churn rule fire early and kill a healthy child, so an
|
|
495
|
+
* `inherit` or unresolved group keeps the parent's number rather than a guess.
|
|
496
|
+
*/
|
|
497
|
+
function childContextWindow(deps, group) {
|
|
498
|
+
return (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow;
|
|
499
|
+
}
|
|
500
|
+
function phaseChildRun(deps,
|
|
501
|
+
/** This child's group, for the window. Undefined ⇒ the run's own window. */
|
|
502
|
+
group, over) {
|
|
326
503
|
return {
|
|
327
504
|
cwd: deps.cwd,
|
|
328
505
|
onLine: deps.onChildOutput,
|
|
329
506
|
onContextUsage: deps.onContextUsage,
|
|
330
507
|
spawn: deps.spawn,
|
|
331
508
|
extensions: deps.childExtensions,
|
|
332
|
-
|
|
509
|
+
// The GROUP's window when this session resolved one, else the run's.
|
|
510
|
+
// Too small a window makes the churn rule fire early and kill a healthy
|
|
511
|
+
// child, so an unresolved group keeps the parent's number.
|
|
512
|
+
contextWindow: (group === undefined ? undefined : groupWindow(group)) ?? deps.contextWindow,
|
|
333
513
|
...over
|
|
334
514
|
};
|
|
335
515
|
}
|
|
@@ -340,41 +520,61 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
|
340
520
|
// between a loop-kill and its retry would otherwise make the two attempts
|
|
341
521
|
// different experiments, and the retry exists to repeat the first one with a
|
|
342
522
|
// hint. An unmapped name inherits, which is today's argv — the build-time
|
|
343
|
-
// guard for that is
|
|
344
|
-
const
|
|
523
|
+
// guard for that is config/groups.test.ts, not a throw in a user's run.
|
|
524
|
+
const groupArgs = groupArgsForChild(name);
|
|
525
|
+
const group = groupForChild(name);
|
|
345
526
|
const verb = opts.verb ?? 'retry';
|
|
346
527
|
let hint = null;
|
|
347
528
|
const loopHistory = [];
|
|
348
529
|
const budgetMs = deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS;
|
|
530
|
+
// From the `phase` row, not from literals here, so the table is the one place
|
|
531
|
+
// that answers "how may this child die". The row resolves to the same
|
|
532
|
+
// DEFAULT_LOOP_DETECTOR / DEFAULT_LOOP_PROGRESS this file used to hard-code.
|
|
533
|
+
const loopGuard = phasePolicy().guards.loop;
|
|
534
|
+
// Watchdog kills, NOT total strikes: a loop restart never saw the
|
|
535
|
+
// bound-your-command hint. Without the halving, three attempts at the default
|
|
536
|
+
// ceiling cost 45 minutes and nothing else bounds this path.
|
|
537
|
+
let hangKills = 0;
|
|
349
538
|
for (let attempt = 0; attempt <= MAX_LEAK_RETRIES; attempt++) {
|
|
350
539
|
// A cancel between attempts must not buy another spawn.
|
|
351
540
|
if (deps.signal.aborted)
|
|
352
541
|
throw new Error(USER_CANCELLED);
|
|
353
|
-
const detector =
|
|
354
|
-
|
|
542
|
+
const detector = loopGuard.detector === false ?
|
|
543
|
+
null
|
|
544
|
+
: new LoopDetector(loopGuard.detector.window, loopGuard.detector.threshold, loopGuard.detector.pathThreshold);
|
|
545
|
+
const stall = loopGuard.progress === false ?
|
|
546
|
+
null
|
|
547
|
+
: new StallDetector(loopGuard.progress.limit, loopGuard.progress.churnFactor);
|
|
355
548
|
// Arm the churn rule BEFORE the first tool call. pi's stream carries no
|
|
356
549
|
// context WINDOW, so a detector that waited to be told one would sit at 0,
|
|
357
550
|
// and the churn rule returns false on a non-positive window. The parent
|
|
358
551
|
// knows the value at spawn time — say it then, not later.
|
|
359
|
-
|
|
552
|
+
//
|
|
553
|
+
// The SAME number the child is handed below. Arming it from the run's
|
|
554
|
+
// window while the child runs on a bigger model's would judge the child
|
|
555
|
+
// against a window it does not have, for exactly the stretch before the
|
|
556
|
+
// first `context_usage` event corrects it — which is the stretch this
|
|
557
|
+
// line exists to cover.
|
|
558
|
+
stall?.noteContext(childContextWindow(deps, group) ?? 0);
|
|
360
559
|
const clock = phaseTimeout(deps.signal, budgetMs);
|
|
361
560
|
let r;
|
|
362
561
|
try {
|
|
363
|
-
r = await runChild(phaseChildRun(deps, {
|
|
562
|
+
r = await runChild(phaseChildRun(deps, group, {
|
|
364
563
|
tools,
|
|
365
564
|
prompt: prependHint(hint, prompt),
|
|
366
565
|
signal: clock.signal,
|
|
367
|
-
|
|
566
|
+
groupArgs,
|
|
368
567
|
onContextUsage: snapshot => {
|
|
369
568
|
// Real window or nothing: `noteContext` ignores 0, which is
|
|
370
569
|
// why the parent's value must be supplied at spawn — a
|
|
371
570
|
// stream that only ever reports 0 leaves the churn rule
|
|
372
571
|
// permanently disarmed.
|
|
373
|
-
stall
|
|
572
|
+
stall?.noteContext(snapshot.contextWindow);
|
|
374
573
|
deps.onContextUsage?.(snapshot);
|
|
375
574
|
},
|
|
376
|
-
|
|
377
|
-
|
|
575
|
+
commandCeilingMs: commandCeilingForAttempt(phasePolicy().guards['command-timeout'], hangKills),
|
|
576
|
+
onToolCall: call => detector?.record(call) ?? stall?.record(call) ?? null,
|
|
577
|
+
onToolResult: (text, isError) => stall?.noteResult(text, isError)
|
|
378
578
|
}));
|
|
379
579
|
}
|
|
380
580
|
finally {
|
|
@@ -404,6 +604,36 @@ export async function runPhaseChild(deps, name, tools, prompt, opts = {}) {
|
|
|
404
604
|
hint = r.loopHit.stall ? formatStallHint(r.loopHit.stall) : formatLoopHint(r.loopHit);
|
|
405
605
|
continue;
|
|
406
606
|
}
|
|
607
|
+
// Ordered after the loop rule and before the clock, matching RESTART_ORDER
|
|
608
|
+
// (worker-kill.ts): the loop hint names the offending call and is the more
|
|
609
|
+
// specific thing to tell a re-spawn, and a watchdog kill leaves the phase
|
|
610
|
+
// clock's own flag false, so the two cannot be confused.
|
|
611
|
+
if (r.commandKill) {
|
|
612
|
+
hangKills++;
|
|
613
|
+
if (attempt === MAX_LEAK_RETRIES) {
|
|
614
|
+
throw new CommandTimeoutError(name, r.commandKill);
|
|
615
|
+
}
|
|
616
|
+
deps.logDebug?.(`${name}: ${r.commandKill.toolName} outran `
|
|
617
|
+
+ `${Math.round(r.commandKill.timeoutMs / 1000)}s — `
|
|
618
|
+
+ `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
|
|
619
|
+
// Tracks the TOOLS, not the phase: verify-tooling holds `read,bash`,
|
|
620
|
+
// and a half-written node_modules survives the kill.
|
|
621
|
+
hint = commandTimeoutHint(r.commandKill.toolName, r.commandKill.timeoutMs, {
|
|
622
|
+
...(r.commandKill.detail ? { commandDetail: r.commandKill.detail } : {}),
|
|
623
|
+
editsMayPersist: /\b(?:bash|edit|write)\b/.test(tools)
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
// A dead-backend kill spends a strike like the others; guardKillError says
|
|
628
|
+
// when the verdict has been earned.
|
|
629
|
+
const killed = guardKillError(name, r, { finalAttempt: attempt === MAX_LEAK_RETRIES });
|
|
630
|
+
if (killed)
|
|
631
|
+
throw killed;
|
|
632
|
+
if (r.stalled) {
|
|
633
|
+
deps.logDebug?.(`${name}: no output for the stall window and no endpoint answered — `
|
|
634
|
+
+ `${verb} ${attempt + 1}/${MAX_LEAK_RETRIES}`);
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
407
637
|
if (clock.timedOut()) {
|
|
408
638
|
if (attempt === MAX_LEAK_RETRIES) {
|
|
409
639
|
throw new PhaseTimeoutError(name, budgetMs, MAX_LEAK_RETRIES + 1);
|
|
@@ -488,19 +718,26 @@ async function runDegradedFinalAttempt(deps, name, prompt, hit, loopHistory) {
|
|
|
488
718
|
const clock = phaseTimeout(deps.signal, deps.timeoutMs ?? PHASE_CHILD_TIMEOUT_MS);
|
|
489
719
|
let r;
|
|
490
720
|
try {
|
|
491
|
-
r = await runChild(phaseChildRun(deps, {
|
|
721
|
+
r = await runChild(phaseChildRun(deps, groupForChild(name), {
|
|
492
722
|
tools: '', // --no-tools: the model cannot read/grep/list, only answer
|
|
493
723
|
prompt: prependHint(formatDegradeHint(hit), prompt),
|
|
494
724
|
signal: clock.signal,
|
|
495
725
|
// Same group as the attempts that led here. The degrade changes the
|
|
496
|
-
// TOOLS, not the role — running it
|
|
497
|
-
// make the fallback a different
|
|
498
|
-
|
|
726
|
+
// TOOLS, not the role — running it on a different model, or at a
|
|
727
|
+
// different thinking level, would make the fallback a different
|
|
728
|
+
// experiment from the thing it rescues.
|
|
729
|
+
groupArgs: groupArgsForChild(name)
|
|
499
730
|
}));
|
|
500
731
|
}
|
|
501
732
|
finally {
|
|
502
733
|
clock.cleanup();
|
|
503
734
|
}
|
|
735
|
+
// BEFORE the exit-code test, not inside it: a guard kill reports exit 0, so
|
|
736
|
+
// asking afterwards would already have returned the truncated text as this
|
|
737
|
+
// phase's deliverable.
|
|
738
|
+
const killed = guardKillError(name, r);
|
|
739
|
+
if (killed)
|
|
740
|
+
throw killed;
|
|
504
741
|
if (r.exitCode !== 0 || r.modelError || r.text.trim().length === 0) {
|
|
505
742
|
// A wall-clock kill is NOT a loop. Without this check a child that outran
|
|
506
743
|
// its budget is reported as "loop budget exhausted", carrying a loop
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
6
6
|
*/
|
|
7
7
|
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
8
|
+
import { type PiTaskConfig } from '../config/config.js';
|
|
9
|
+
import type { ChildGroup } from '../config/groups.js';
|
|
8
10
|
/**
|
|
9
11
|
* The parent session's context window, or 0 when the model doesn't expose it.
|
|
10
12
|
*
|
|
@@ -22,6 +24,50 @@ export declare function getParentContextWindow(ctx: {
|
|
|
22
24
|
contextWindow?: number;
|
|
23
25
|
};
|
|
24
26
|
}): number;
|
|
27
|
+
/**
|
|
28
|
+
* The window for the model ONE GROUP's children will actually run on.
|
|
29
|
+
*
|
|
30
|
+
* This number drives the widget and, more importantly, `StallDetector`'s
|
|
31
|
+
* context-churn rule, and the two error directions are not symmetric. A parent
|
|
32
|
+
* window LARGER than the child's makes churn fire late — degraded, and the
|
|
33
|
+
* no-new-ground rule still covers it. A parent window SMALLER makes churn fire
|
|
34
|
+
* early and KILL A HEALTHY CHILD. A big-context research model under a small
|
|
35
|
+
* host model is a real false positive, which is why this exists at all.
|
|
36
|
+
*
|
|
37
|
+
* For the same reason there is no `min(parent, group)`: that would import the
|
|
38
|
+
* dangerous direction on purpose.
|
|
39
|
+
*
|
|
40
|
+
* `inherit`, an unresolvable spec, or a model with no declared window all return
|
|
41
|
+
* exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
|
|
42
|
+
* per-group models existed.
|
|
43
|
+
*
|
|
44
|
+
* Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
|
|
45
|
+
* which the session pass fills from this. One producer, so the two views cannot
|
|
46
|
+
* describe different models.
|
|
47
|
+
*/
|
|
48
|
+
export declare function contextWindowForGroup(ctx: {
|
|
49
|
+
model?: {
|
|
50
|
+
contextWindow?: number;
|
|
51
|
+
};
|
|
52
|
+
modelRegistry?: {
|
|
53
|
+
find: (p: string, i: string) => unknown;
|
|
54
|
+
};
|
|
55
|
+
}, group: ChildGroup, cfg?: PiTaskConfig): number;
|
|
56
|
+
/**
|
|
57
|
+
* The same answer for a spec the caller already has.
|
|
58
|
+
*
|
|
59
|
+
* The session pass needs this: it walks an INJECTED spec table, and reaching for
|
|
60
|
+
* `getConfig()` here would let the window it stores describe a different model
|
|
61
|
+
* from the one it just checked.
|
|
62
|
+
*/
|
|
63
|
+
export declare function contextWindowForSpec(ctx: {
|
|
64
|
+
model?: {
|
|
65
|
+
contextWindow?: number;
|
|
66
|
+
};
|
|
67
|
+
modelRegistry?: {
|
|
68
|
+
find: (p: string, i: string) => unknown;
|
|
69
|
+
};
|
|
70
|
+
}, spec: string): number;
|
|
25
71
|
/**
|
|
26
72
|
* Fold a raw context snapshot into a display snapshot: prefer the child's own
|
|
27
73
|
* contextWindow, else the last known one, else the parent session's; then derive
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
5
|
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
6
6
|
*/
|
|
7
|
+
import { getConfig } from '../config/config.js';
|
|
8
|
+
import { MODEL_INHERIT, splitSpec } from '../config/group-models.js';
|
|
7
9
|
/**
|
|
8
10
|
* The parent session's context window, or 0 when the model doesn't expose it.
|
|
9
11
|
*
|
|
@@ -19,6 +21,45 @@
|
|
|
19
21
|
export function getParentContextWindow(ctx) {
|
|
20
22
|
return ctx.model?.contextWindow ?? 0;
|
|
21
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* The window for the model ONE GROUP's children will actually run on.
|
|
26
|
+
*
|
|
27
|
+
* This number drives the widget and, more importantly, `StallDetector`'s
|
|
28
|
+
* context-churn rule, and the two error directions are not symmetric. A parent
|
|
29
|
+
* window LARGER than the child's makes churn fire late — degraded, and the
|
|
30
|
+
* no-new-ground rule still covers it. A parent window SMALLER makes churn fire
|
|
31
|
+
* early and KILL A HEALTHY CHILD. A big-context research model under a small
|
|
32
|
+
* host model is a real false positive, which is why this exists at all.
|
|
33
|
+
*
|
|
34
|
+
* For the same reason there is no `min(parent, group)`: that would import the
|
|
35
|
+
* dangerous direction on purpose.
|
|
36
|
+
*
|
|
37
|
+
* `inherit`, an unresolvable spec, or a model with no declared window all return
|
|
38
|
+
* exactly `getParentContextWindow(ctx)` — byte-identical to the behaviour before
|
|
39
|
+
* per-group models existed.
|
|
40
|
+
*
|
|
41
|
+
* Callers WITHOUT a ctx read `groupWindow` from config/group-args.ts instead,
|
|
42
|
+
* which the session pass fills from this. One producer, so the two views cannot
|
|
43
|
+
* describe different models.
|
|
44
|
+
*/
|
|
45
|
+
export function contextWindowForGroup(ctx, group, cfg = getConfig()) {
|
|
46
|
+
return contextWindowForSpec(ctx, cfg.groupModels[group]);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The same answer for a spec the caller already has.
|
|
50
|
+
*
|
|
51
|
+
* The session pass needs this: it walks an INJECTED spec table, and reaching for
|
|
52
|
+
* `getConfig()` here would let the window it stores describe a different model
|
|
53
|
+
* from the one it just checked.
|
|
54
|
+
*/
|
|
55
|
+
export function contextWindowForSpec(ctx, spec) {
|
|
56
|
+
if (spec === MODEL_INHERIT)
|
|
57
|
+
return getParentContextWindow(ctx);
|
|
58
|
+
const parts = splitSpec(spec);
|
|
59
|
+
const found = parts && ctx.modelRegistry?.find(parts.provider, parts.id);
|
|
60
|
+
const window = found?.contextWindow ?? 0;
|
|
61
|
+
return window > 0 ? window : getParentContextWindow(ctx);
|
|
62
|
+
}
|
|
22
63
|
/**
|
|
23
64
|
* Fold a raw context snapshot into a display snapshot: prefer the child's own
|
|
24
65
|
* contextWindow, else the last known one, else the parent session's; then derive
|