@mjasnikovs/pi-task 0.38.30 → 0.38.31
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/index.js +2 -0
- package/dist/shared/command-watchdog.d.ts +63 -0
- package/dist/shared/command-watchdog.js +87 -0
- package/dist/task/auto-orchestrator.js +14 -3
- package/dist/task/child-runner.d.ts +78 -4
- package/dist/task/child-runner.js +251 -42
- package/dist/task/failure-classifier.js +24 -1
- package/dist/task/implementation-guards.d.ts +26 -0
- package/dist/task/implementation-guards.js +177 -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/orchestrator.js +27 -2
- package/dist/task/phases.js +17 -4
- package/dist/workers/focused-extractor.d.ts +2 -1
- package/dist/workers/focused-extractor.js +12 -1
- package/dist/workers/pi-worker-core.d.ts +2 -31
- package/dist/workers/pi-worker-core.js +4 -83
- 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/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import { registerWorkers } from './workers/index.js';
|
|
|
6
6
|
import { registerRemote } from './remote/register.js';
|
|
7
7
|
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
8
8
|
import { registerStreamWatchdog } from './task/stream-watchdog.js';
|
|
9
|
+
import { registerImplementationGuards } from './task/implementation-guards.js';
|
|
9
10
|
export default function (pi) {
|
|
10
11
|
registerConfig(pi);
|
|
11
12
|
registerTask(pi);
|
|
@@ -15,4 +16,5 @@ export default function (pi) {
|
|
|
15
16
|
registerRemote(pi);
|
|
16
17
|
registerCommandWatchdog(pi);
|
|
17
18
|
registerStreamWatchdog(pi);
|
|
19
|
+
registerImplementationGuards(pi);
|
|
18
20
|
}
|
|
@@ -122,3 +122,66 @@ export declare class CommandWatchdog {
|
|
|
122
122
|
* the call it watches.
|
|
123
123
|
*/
|
|
124
124
|
export declare const realTimerDeps: Pick<WatchdogDeps, 'schedule' | 'cancel'>;
|
|
125
|
+
/** What the command watchdog recorded when it killed a child attempt. */
|
|
126
|
+
export interface CommandKill {
|
|
127
|
+
toolName: string;
|
|
128
|
+
timeoutMs: number;
|
|
129
|
+
/** The command line itself, when the tool carried one — quoted into the hint
|
|
130
|
+
* so the fresh child knows which call it must not repeat unbounded. */
|
|
131
|
+
detail?: string;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* The tool-call fields the child-side watchdog reads. Structural rather than
|
|
135
|
+
* `ToolCall` from child-process.ts, so this module keeps its zero imports and a
|
|
136
|
+
* caller cannot be forced to reach for the runner's types to arm a timer.
|
|
137
|
+
*/
|
|
138
|
+
export interface WatchedToolCall {
|
|
139
|
+
name: string;
|
|
140
|
+
toolCallId?: string;
|
|
141
|
+
args: unknown;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
145
|
+
* machine whose `onFire` aborts `signal`, which runChild turns into a
|
|
146
|
+
* process-GROUP kill — reaping the hung command itself, not just the pi child
|
|
147
|
+
* holding it.
|
|
148
|
+
*
|
|
149
|
+
* LIMIT: the group kill only reaches processes still IN the group. A hung command
|
|
150
|
+
* that detached a daemon (setsid, nohup, a background dev server) leaves it
|
|
151
|
+
* running, so the fresh attempt can hit a port the dead attempt's escapee still
|
|
152
|
+
* holds. There is no cheap fix from here; the restart hint's "check current state"
|
|
153
|
+
* line is the mitigation.
|
|
154
|
+
*
|
|
155
|
+
* Returns null when the watchdog is off, so the caller keeps the plain timeout
|
|
156
|
+
* signal and no per-call bookkeeping happens at all.
|
|
157
|
+
*/
|
|
158
|
+
export declare function commandWatch(timeoutMs: number): {
|
|
159
|
+
onStart: (call: WatchedToolCall) => void;
|
|
160
|
+
onEnd: (toolCallId: string | undefined) => void;
|
|
161
|
+
killed: () => CommandKill | undefined;
|
|
162
|
+
signal: AbortSignal;
|
|
163
|
+
clear: () => void;
|
|
164
|
+
} | null;
|
|
165
|
+
/**
|
|
166
|
+
* The per-command ceiling for attempt N, halving each time a hang recurs.
|
|
167
|
+
*
|
|
168
|
+
* The first attempt gets the full configured ceiling — a genuinely slow build or
|
|
169
|
+
* test suite deserves it. But every hang-caused restart carries
|
|
170
|
+
* commandTimeoutHint, which tells the model in as many words to bound its
|
|
171
|
+
* command; a SECOND hang means it ignored an explicit instruction, and a third
|
|
172
|
+
* means it ignored it twice. Giving a non-complying child the full ceiling again
|
|
173
|
+
* makes the worst case three times the ceiling, resting entirely on the model
|
|
174
|
+
* obeying prose. Halving bounds it at under twice the ceiling while costing a
|
|
175
|
+
* complying child nothing.
|
|
176
|
+
*
|
|
177
|
+
* `priorHangs` counts watchdog kills specifically, NOT total restarts — the
|
|
178
|
+
* restart budget is shared with loop kills, and a child restarted for LOOPING
|
|
179
|
+
* never received the bound-your-command hint, so its first hang still deserves
|
|
180
|
+
* the full ceiling. Only a hang after a hang is defiance.
|
|
181
|
+
*
|
|
182
|
+
* Floored at 30s so repeated halving cannot shrink the ceiling to something no
|
|
183
|
+
* real command could finish inside — but the floor is `min(base, 30s)`, never
|
|
184
|
+
* above the configured ceiling, so a caller asking for 10s keeps 10s at every
|
|
185
|
+
* hang count. A base of 0 or less disables the watchdog and stays 0.
|
|
186
|
+
*/
|
|
187
|
+
export declare function commandCeilingForAttempt(baseMs: number, priorHangs: number): number;
|
|
@@ -172,3 +172,90 @@ export const realTimerDeps = {
|
|
|
172
172
|
schedule: (fn, ms) => setTimeout(fn, ms),
|
|
173
173
|
cancel: handle => clearTimeout(handle)
|
|
174
174
|
};
|
|
175
|
+
/**
|
|
176
|
+
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
177
|
+
* machine whose `onFire` aborts `signal`, which runChild turns into a
|
|
178
|
+
* process-GROUP kill — reaping the hung command itself, not just the pi child
|
|
179
|
+
* holding it.
|
|
180
|
+
*
|
|
181
|
+
* LIMIT: the group kill only reaches processes still IN the group. A hung command
|
|
182
|
+
* that detached a daemon (setsid, nohup, a background dev server) leaves it
|
|
183
|
+
* running, so the fresh attempt can hit a port the dead attempt's escapee still
|
|
184
|
+
* holds. There is no cheap fix from here; the restart hint's "check current state"
|
|
185
|
+
* line is the mitigation.
|
|
186
|
+
*
|
|
187
|
+
* Returns null when the watchdog is off, so the caller keeps the plain timeout
|
|
188
|
+
* signal and no per-call bookkeeping happens at all.
|
|
189
|
+
*/
|
|
190
|
+
export function commandWatch(timeoutMs) {
|
|
191
|
+
if (!(timeoutMs > 0))
|
|
192
|
+
return null;
|
|
193
|
+
const ctrl = new AbortController();
|
|
194
|
+
// pi's toolCallId pairs start↔end. When it is absent (a fake stream in a
|
|
195
|
+
// test, an older pi), fall back to one shared slot: tool executions in a
|
|
196
|
+
// child are sequential, so a single slot is still correctly paired.
|
|
197
|
+
const key = (id) => id ?? 'anon';
|
|
198
|
+
const details = new Map();
|
|
199
|
+
let killed;
|
|
200
|
+
const watchdog = new CommandWatchdog({
|
|
201
|
+
getTimeoutMs: () => timeoutMs,
|
|
202
|
+
...realTimerDeps,
|
|
203
|
+
onFire: (toolCallId, toolName, ms) => {
|
|
204
|
+
killed = {
|
|
205
|
+
toolName,
|
|
206
|
+
timeoutMs: ms,
|
|
207
|
+
...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
|
|
208
|
+
};
|
|
209
|
+
ctrl.abort();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
onStart: call => {
|
|
214
|
+
const id = key(call.toolCallId);
|
|
215
|
+
const args = call.args;
|
|
216
|
+
if (typeof args?.command === 'string') {
|
|
217
|
+
details.set(id, args.command.slice(0, 120));
|
|
218
|
+
}
|
|
219
|
+
watchdog.onStart(id, call.name);
|
|
220
|
+
},
|
|
221
|
+
onEnd: id => {
|
|
222
|
+
// Drop the command line with its call. The `'anon'` fallback above is a
|
|
223
|
+
// SHARED slot, so a stale entry would be attributed to whatever ran
|
|
224
|
+
// next: a `read` that later overran would be reported as
|
|
225
|
+
// "ran a `read` command (bun run dev)".
|
|
226
|
+
details.delete(key(id));
|
|
227
|
+
watchdog.onEnd(key(id));
|
|
228
|
+
},
|
|
229
|
+
killed: () => killed,
|
|
230
|
+
signal: ctrl.signal,
|
|
231
|
+
clear: () => watchdog.clearAll()
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The per-command ceiling for attempt N, halving each time a hang recurs.
|
|
236
|
+
*
|
|
237
|
+
* The first attempt gets the full configured ceiling — a genuinely slow build or
|
|
238
|
+
* test suite deserves it. But every hang-caused restart carries
|
|
239
|
+
* commandTimeoutHint, which tells the model in as many words to bound its
|
|
240
|
+
* command; a SECOND hang means it ignored an explicit instruction, and a third
|
|
241
|
+
* means it ignored it twice. Giving a non-complying child the full ceiling again
|
|
242
|
+
* makes the worst case three times the ceiling, resting entirely on the model
|
|
243
|
+
* obeying prose. Halving bounds it at under twice the ceiling while costing a
|
|
244
|
+
* complying child nothing.
|
|
245
|
+
*
|
|
246
|
+
* `priorHangs` counts watchdog kills specifically, NOT total restarts — the
|
|
247
|
+
* restart budget is shared with loop kills, and a child restarted for LOOPING
|
|
248
|
+
* never received the bound-your-command hint, so its first hang still deserves
|
|
249
|
+
* the full ceiling. Only a hang after a hang is defiance.
|
|
250
|
+
*
|
|
251
|
+
* Floored at 30s so repeated halving cannot shrink the ceiling to something no
|
|
252
|
+
* real command could finish inside — but the floor is `min(base, 30s)`, never
|
|
253
|
+
* above the configured ceiling, so a caller asking for 10s keeps 10s at every
|
|
254
|
+
* hang count. A base of 0 or less disables the watchdog and stays 0.
|
|
255
|
+
*/
|
|
256
|
+
export function commandCeilingForAttempt(baseMs, priorHangs) {
|
|
257
|
+
if (!(baseMs > 0))
|
|
258
|
+
return 0;
|
|
259
|
+
const floor = Math.min(baseMs, 30_000);
|
|
260
|
+
return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
|
|
261
|
+
}
|
|
@@ -22,7 +22,7 @@ import { drainRepairQueue, mergeRepairCandidates, planHasRepairFor, parseRepairT
|
|
|
22
22
|
import { writeTaskFile, readTaskFile, updateTaskFrontMatter, taskFilePath, tasksDir } from './task-io.js';
|
|
23
23
|
import { readTextFile } from '../shared/fs-text.js';
|
|
24
24
|
import { findPhantomImports, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
25
|
-
import { prependHint, USER_CANCELLED } from './child-runner.js';
|
|
25
|
+
import { isFatalChildCause, prependHint, USER_CANCELLED } from './child-runner.js';
|
|
26
26
|
import { requestCancel, resetCancel, isCancelRequested, cancelCheckpoint } from './cancel-points.js';
|
|
27
27
|
import { withRun, announceTerminal } from './run-bracket.js';
|
|
28
28
|
import { refineExistingFilesBlock, SINGLE_READ_EXTENSION_PATH } from './phases.js';
|
|
@@ -493,8 +493,15 @@ export async function orientFeature(cwd, feature, deps) {
|
|
|
493
493
|
reqEntries = capRequirements(reqEntries, passages, featureForModel);
|
|
494
494
|
logPlanDebug(cwd, `requirement extraction: ${reqEntries.length} grounded requirement(s) kept`);
|
|
495
495
|
}
|
|
496
|
-
catch {
|
|
497
|
-
//
|
|
496
|
+
catch (e) {
|
|
497
|
+
// Best-effort covers a child that answered badly. It must NOT cover a user
|
|
498
|
+
// ESC or a dead backend: planning would continue on an EMPTY ledger, moving
|
|
499
|
+
// the granularity floor and shipping a degraded plan instead of a cancel or
|
|
500
|
+
// a failure. Same rule as verify-resolution.ts.
|
|
501
|
+
if (isFatalChildCause(e))
|
|
502
|
+
throw e;
|
|
503
|
+
// Best-effort, but not silent: nothing else records a guard kill here.
|
|
504
|
+
logPlanDebug(cwd, `requirement extraction: skipped — ${e.message}`);
|
|
498
505
|
}
|
|
499
506
|
// Granularity floor: without it the plan's task COUNT is set by an
|
|
500
507
|
// auto-resolved clarify line the user never sees, so the same spec and the same
|
|
@@ -1101,8 +1108,12 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
1101
1108
|
const status = new ChildStatus({ parentContextWindow });
|
|
1102
1109
|
const phaseDeps = {
|
|
1103
1110
|
cwd,
|
|
1111
|
+
// No task file, so appendLoopEvent swallows its ENOENT. Its docblock
|
|
1112
|
+
// allows that because "the kill is already reported through the debug
|
|
1113
|
+
// log" — which is why logDebug below is not optional here.
|
|
1104
1114
|
taskId: '',
|
|
1105
1115
|
signal,
|
|
1116
|
+
logDebug: msg => logPlanDebug(cwd, msg),
|
|
1106
1117
|
// IN-RUN thrash guard for the planning children: without it a decompose
|
|
1107
1118
|
// child can re-read its design document until it fills the whole context
|
|
1108
1119
|
// window, and never return. Every planning child
|
|
@@ -6,15 +6,14 @@
|
|
|
6
6
|
* for phase-level child pi invocations.
|
|
7
7
|
*/
|
|
8
8
|
import { type SpawnFn, type ContextSnapshot, type ToolCall, type LoopHit } from '../shared/child-process.js';
|
|
9
|
+
import { type CommandKill } from '../shared/command-watchdog.js';
|
|
10
|
+
import { type WorkerGuardPolicy } from '../workers/worker-profiles.js';
|
|
9
11
|
import type { DebugLine } from './debug-log.js';
|
|
10
12
|
import type { RunWorkerInput, RunWorkerResult } from '../workers/pi-worker-core.js';
|
|
11
13
|
import type { docsRaw, docsFocused } from '../workers/docs-core.js';
|
|
12
14
|
import type { fetchRaw, fetchFocused } from '../workers/fetch-core.js';
|
|
13
15
|
import type { npmVersionLookup } from '../workers/npm-version.js';
|
|
14
16
|
import type { SearchCoreInput, SearchCoreResult } from '../workers/search-core.js';
|
|
15
|
-
export declare const LOOP_WINDOW = 20;
|
|
16
|
-
export declare const LOOP_THRESHOLD = 5;
|
|
17
|
-
export declare const MAX_LOOP_RESTARTS = 2;
|
|
18
17
|
/**
|
|
19
18
|
* Optional wall-clock bound on ONE spawn of a phase child. DEFAULT: OFF.
|
|
20
19
|
*
|
|
@@ -50,6 +49,54 @@ export declare class PhaseTimeoutError extends Error {
|
|
|
50
49
|
readonly attempts: number;
|
|
51
50
|
constructor(childName: string, budgetMs: number, attempts: number);
|
|
52
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The terminal error for a guard kill, or null when the child was not killed.
|
|
54
|
+
*
|
|
55
|
+
* Both spawn paths must ask. A kill reports `exitCode: 0` (child-process.ts uses
|
|
56
|
+
* `code ?? 0`, and a signal gives null), so a path that tests the exit code
|
|
57
|
+
* instead returns the truncated text as the phase's answer.
|
|
58
|
+
*/
|
|
59
|
+
export declare function guardKillError(name: string, r: PhaseRunResult, opts?: {
|
|
60
|
+
finalAttempt?: boolean;
|
|
61
|
+
}): Error | null;
|
|
62
|
+
/**
|
|
63
|
+
* The dead-backend probe killed a phase child on its LAST attempt.
|
|
64
|
+
*
|
|
65
|
+
* Reaching this means every attempt found no endpoint answering, not one. The
|
|
66
|
+
* single-probe verdict is not trusted on its own: `discoverModelEndpoints` reads
|
|
67
|
+
* every provider in models.json rather than the one this child's model uses, so a
|
|
68
|
+
* stopped local server can condemn a run against a healthy cloud backend. Three
|
|
69
|
+
* failed probes cost ~15s; one wrong verdict costs the run.
|
|
70
|
+
*/
|
|
71
|
+
export declare class BackendDownError extends Error {
|
|
72
|
+
readonly childName: string;
|
|
73
|
+
constructor(childName: string);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A phase child spent every attempt on a command that never returned. Its own
|
|
77
|
+
* class because the fix is in the SPEC, not the model's exploration: a VERIFY
|
|
78
|
+
* block naming an unbounded `dev` command re-hangs every attempt.
|
|
79
|
+
*/
|
|
80
|
+
export declare class CommandTimeoutError extends Error {
|
|
81
|
+
readonly childName: string;
|
|
82
|
+
readonly kill: CommandKill;
|
|
83
|
+
constructor(childName: string, kill: CommandKill);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Causes a best-effort `catch` must NOT absorb.
|
|
87
|
+
*
|
|
88
|
+
* A phase child that merely answered badly should degrade — that is what those
|
|
89
|
+
* catches are for. These two are different in kind: the run is over either way,
|
|
90
|
+
* and swallowing them ships a half-built spec while every later phase dies
|
|
91
|
+
* against the same dead backend, or turns a user's ESC into silent progress.
|
|
92
|
+
* `failure-classifier.ts` has a verdict for both; a catch that eats them makes it
|
|
93
|
+
* unreachable.
|
|
94
|
+
*/
|
|
95
|
+
export declare function isFatalChildCause(e: unknown): boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Retry budget is three attempts at 500ms/1s/2s — three requests over 3.5s, which
|
|
98
|
+
* is not a storm even against a throttle. pi's own ladder is three at 2s/4s/8s.
|
|
99
|
+
*/
|
|
53
100
|
export declare function isConnectionError(cause: string): boolean;
|
|
54
101
|
/** Exponential backoff before a connection-error retry: 500ms, 1s, 2s, …, so a
|
|
55
102
|
* brief saturation window can drain before we re-issue the request. */
|
|
@@ -63,6 +110,18 @@ export interface PhaseRunResult {
|
|
|
63
110
|
leakedToolCall?: string;
|
|
64
111
|
/** Set when the child's final turn failed with stopReason "error" (model/provider failure). */
|
|
65
112
|
modelError?: string;
|
|
113
|
+
/**
|
|
114
|
+
* Set when the per-command watchdog killed the child: one tool call outran
|
|
115
|
+
* `requestTimeoutMs`. RESTARTABLE (worker-kill.ts) — a hung command is a
|
|
116
|
+
* mistake the next attempt can be told not to repeat.
|
|
117
|
+
*/
|
|
118
|
+
commandKill?: CommandKill;
|
|
119
|
+
/**
|
|
120
|
+
* Set when the dead-backend probe killed the child: no output for the stall
|
|
121
|
+
* window AND the model endpoint unreachable. NOT restartable (worker-kill.ts):
|
|
122
|
+
* re-spawning against a backend that is down buys nothing.
|
|
123
|
+
*/
|
|
124
|
+
stalled?: boolean;
|
|
66
125
|
}
|
|
67
126
|
export declare function childArgs(tools: string, extensions?: readonly string[],
|
|
68
127
|
/**
|
|
@@ -120,8 +179,23 @@ export interface ChildRun {
|
|
|
120
179
|
* group, or `[]`/omitted to inherit the session default as before.
|
|
121
180
|
*/
|
|
122
181
|
thinking?: readonly string[];
|
|
182
|
+
/**
|
|
183
|
+
* This attempt's per-command ceiling, already halved for prior hangs by the
|
|
184
|
+
* caller's strike loop. Omitted -> the `phase` row's full configured ceiling,
|
|
185
|
+
* which is the right value for a single-attempt caller.
|
|
186
|
+
*/
|
|
187
|
+
commandCeilingMs?: number;
|
|
123
188
|
}
|
|
124
|
-
|
|
189
|
+
/**
|
|
190
|
+
* The `phase` row of WORKER_PROFILES, resolved with this machine's config.
|
|
191
|
+
*
|
|
192
|
+
* Read here rather than at module load so a /task-config change reaches the next
|
|
193
|
+
* child, the same contract childBaseArgs already keeps. Both spawn paths in this
|
|
194
|
+
* file go through it, so the degraded final attempt cannot drift from the ordinary
|
|
195
|
+
* one — the mislabel class runDegradedFinalAttempt's own comment warns about.
|
|
196
|
+
*/
|
|
197
|
+
export declare function phasePolicy(): WorkerGuardPolicy;
|
|
198
|
+
export declare function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking, commandCeilingMs }: ChildRun): Promise<PhaseRunResult>;
|
|
125
199
|
export interface PhaseDeps {
|
|
126
200
|
cwd: string;
|
|
127
201
|
taskId: string;
|