@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 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
- // best-effort channel
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
- export declare function runChild({ cwd, tools, prompt, signal, onLine, onContextUsage, onToolCall, spawn: spawnFn, extensions, onToolResult, contextWindow, thinking }: ChildRun): Promise<PhaseRunResult>;
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;