@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.
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { SessionUI } from '../remote/bridge.js';
19
19
  import { consumeWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
20
+ import { consumeGuardTermination } from './implementation-guards.js';
20
21
  const isAssistant = (e) => e.message !== undefined && e.message.role === 'assistant';
21
22
  /** Index of the last assistant message and of the last compaction boundary. */
22
23
  function tailPositions(entries) {
@@ -160,6 +161,9 @@ export const CONTINUE_AFTER_COMPACTION = 'Your context was automatically compact
160
161
  * lets the verify gate and `/task-auto-resume` catch any leftover incompleteness.
161
162
  */
162
163
  export const MAX_COMPACTION_RESUMES = 20;
164
+ /** How a guard-stopped turn is reported. Named so a caller can tell it from a
165
+ * provider error: the fix is a different task, not a retry of this one. */
166
+ export const GUARD_TERMINATED = 'the runaway guard stopped this turn: one tool call was repeated past every warning';
163
167
  /**
164
168
  * Resume an implementation turn that went idle at a threshold-compaction boundary.
165
169
  * The runtime compacts and parks at idle without auto-continuing; we send a
@@ -260,6 +264,13 @@ export async function superviseWith(deps) {
260
264
  const interrupted = await steerUntilDone(deps);
261
265
  // A user-declined steer (interrupted) is its own paused path; otherwise
262
266
  // inspect how the turn actually ended.
263
- const error = interrupted ? undefined : turnErrorMessage(deps.entries());
267
+ // The runaway guard ends a turn WITHOUT an error stopReason, so classifyTurnEnd
268
+ // reads `'stop'` and the caller would verify a half-done implementation and
269
+ // re-deliver to a model that deterministically re-thrashes. Consumed here
270
+ // because this is the one place that reports how the turn really ended.
271
+ const guardEnded = deps.consumeGuardTermination?.() ?? consumeGuardTermination();
272
+ const error = interrupted ? undefined
273
+ : guardEnded ? GUARD_TERMINATED
274
+ : turnErrorMessage(deps.entries());
264
275
  return { interrupted, error, resumes };
265
276
  }
@@ -12,7 +12,19 @@
12
12
  *
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
15
+ *
16
+ * The three tuning constants live here rather than in child-runner.ts because
17
+ * worker-profiles.ts reads them at module top level to build DEFAULT_LOOP_DETECTOR.
18
+ * From child-runner.ts that is a cycle — child-runner → worker-profiles →
19
+ * child-runner — and the failure is a TDZ ReferenceError on import order, which
20
+ * no compile step catches. This module imports nothing, so it cannot close one.
15
21
  */
22
+ /** Recent tool calls the exact-repeat rule looks back over. */
23
+ export declare const LOOP_WINDOW = 20;
24
+ /** Repeats of one key within the window that trip the detector. */
25
+ export declare const LOOP_THRESHOLD = 5;
26
+ /** Re-spawns allowed after a loop kill — 3 attempts total with the initial one. */
27
+ export declare const MAX_LOOP_RESTARTS = 2;
16
28
  export interface ToolCall {
17
29
  name: string;
18
30
  args: unknown;
@@ -27,6 +39,12 @@ export interface LoopHit {
27
39
  * Arrays preserve their order (positional). undefined / primitives passthrough.
28
40
  */
29
41
  export declare function stableStringify(value: unknown): string;
42
+ /**
43
+ * The exact-match identity of a tool call. Exported because implementation-guards.ts
44
+ * keys its own per-call strike count on it, and a second hand-written copy of this
45
+ * expression is free to drift away from the one `record` uses.
46
+ */
47
+ export declare function loopKey(call: ToolCall): string;
30
48
  /**
31
49
  * The primary file path a tool call targets, mirroring summarizeToolArgs' field
32
50
  * precedence. Returns null when the call names no path (e.g. bash, or a grep
@@ -12,7 +12,19 @@
12
12
  *
13
13
  * Either pattern returns a LoopHit so the caller can kill the child and re-spawn
14
14
  * with a hint. No I/O. No imports from index.ts. Trivially unit-testable.
15
+ *
16
+ * The three tuning constants live here rather than in child-runner.ts because
17
+ * worker-profiles.ts reads them at module top level to build DEFAULT_LOOP_DETECTOR.
18
+ * From child-runner.ts that is a cycle — child-runner → worker-profiles →
19
+ * child-runner — and the failure is a TDZ ReferenceError on import order, which
20
+ * no compile step catches. This module imports nothing, so it cannot close one.
15
21
  */
22
+ /** Recent tool calls the exact-repeat rule looks back over. */
23
+ export const LOOP_WINDOW = 20;
24
+ /** Repeats of one key within the window that trip the detector. */
25
+ export const LOOP_THRESHOLD = 5;
26
+ /** Re-spawns allowed after a loop kill — 3 attempts total with the initial one. */
27
+ export const MAX_LOOP_RESTARTS = 2;
16
28
  /**
17
29
  * JSON.stringify with sorted object keys so {a:1,b:2} and {b:2,a:1} hash equal.
18
30
  * Arrays preserve their order (positional). undefined / primitives passthrough.
@@ -28,6 +40,14 @@ export function stableStringify(value) {
28
40
  return sorted;
29
41
  });
30
42
  }
43
+ /**
44
+ * The exact-match identity of a tool call. Exported because implementation-guards.ts
45
+ * keys its own per-call strike count on it, and a second hand-written copy of this
46
+ * expression is free to drift away from the one `record` uses.
47
+ */
48
+ export function loopKey(call) {
49
+ return `${call.name}\x00${stableStringify(call.args)}`;
50
+ }
31
51
  /**
32
52
  * The primary file path a tool call targets, mirroring summarizeToolArgs' field
33
53
  * precedence. Returns null when the call names no path (e.g. bash, or a grep
@@ -78,7 +98,7 @@ export class LoopDetector {
78
98
  threshold;
79
99
  pathThreshold;
80
100
  buf = [];
81
- constructor(window = 20, threshold = 5,
101
+ constructor(window = LOOP_WINDOW, threshold = LOOP_THRESHOLD,
82
102
  /** Revisits of one path needed to trip; defaults to the exact threshold. */
83
103
  pathThreshold = threshold) {
84
104
  this.window = window;
@@ -87,7 +107,7 @@ export class LoopDetector {
87
107
  }
88
108
  /** Record a tool call. Returns LoopHit if either threshold is breached, else null. */
89
109
  record(call) {
90
- const key = `${call.name}\x00${stableStringify(call.args)}`;
110
+ const key = loopKey(call);
91
111
  const offset = readOffset(call.args);
92
112
  this.buf.push({ key, path: primaryPath(call.args), offset, end: readEnd(call.args, offset) });
93
113
  if (this.buf.length > this.window)
@@ -26,6 +26,7 @@ import { readTextFile } from '../shared/fs-text.js';
26
26
  import { allocateTaskId, ensureTasksDir, readSection, readTaskFile, setTaskSection, taskFilePath, tasksDir, updateTaskFrontMatter, writeTaskFile } from './task-io.js';
27
27
  import { startWidget } from './widget.js';
28
28
  import { armImplWidget, disarmImplWidget, setupImplWidget } from './impl-widget.js';
29
+ import { armImplementationGuard, disarmImplementationGuard } from './implementation-guards.js';
29
30
  import { publishViewer, publishNotify, registerBridgeCommand, getBridge } from '../remote/bridge.js';
30
31
  import { pushNotify } from '../remote/push.js';
31
32
  import { getConfig } from '../config/config.js';
@@ -364,12 +365,24 @@ export class TaskRunner {
364
365
  };
365
366
  if (this._sendSpec) {
366
367
  armImplWidget(meta, { oneShot: !this._implAwaited });
368
+ // Same lifetime as the widget, and for the same reason: an awaited run
369
+ // spans resume and steer turns, a fire-and-forget one does not.
370
+ armImplementationGuard({ oneShot: !this._implAwaited });
371
+ let delivered = false;
367
372
  try {
368
373
  await this._sendSpec(spec);
374
+ delivered = true;
369
375
  }
370
376
  finally {
371
- if (this._implAwaited)
377
+ // Arming precedes delivery because the turn can begin inside it. So a
378
+ // delivery that THREW leaves a guard watching a turn that will never
379
+ // run: pi's `prompt` rejects on a compaction already in progress, a
380
+ // missing model, or a failed auth. The next unrelated turn would
381
+ // inherit it, and this guard can end a turn outright.
382
+ if (this._implAwaited || !delivered) {
372
383
  disarmImplWidget();
384
+ disarmImplementationGuard();
385
+ }
373
386
  }
374
387
  return;
375
388
  }
@@ -377,12 +390,24 @@ export class TaskRunner {
377
390
  throw new Error('extension not initialised (no ExtensionAPI captured)');
378
391
  }
379
392
  armImplWidget(meta, { oneShot: true });
393
+ armImplementationGuard({ oneShot: true });
394
+ // Same reason as the awaited path's `delivered` flag: this send can throw
395
+ // SYNCHRONOUSLY — the loader gates every ExtensionAPI action behind
396
+ // `assertActive()` — and a guard left armed over a turn that never starts
397
+ // is inherited by the next one, which it can terminate.
380
398
  // Always name a delivery mode. pi's `prompt()` consults `streamingBehavior`
381
399
  // only inside `if (this.isStreaming)`, so naming one is inert on an idle
382
400
  // session and queues on a busy one — correct in both cases, where an
383
401
  // isIdle() check is a check-then-act race that loses to any turn starting
384
402
  // in between.
385
- piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
403
+ try {
404
+ piApi.sendUserMessage(spec, { deliverAs: 'followUp' });
405
+ }
406
+ catch (e) {
407
+ disarmImplWidget();
408
+ disarmImplementationGuard();
409
+ throw e;
410
+ }
386
411
  }
387
412
  /**
388
413
  * The spec as the implementer should receive it (Layer B). Layer A strips phantom
@@ -38,7 +38,7 @@ import { readContracts, buildContractsBlock, buildContractsVerifyBlock } from '.
38
38
  import { readRequirements, buildRequirementsBlock, buildOwnedRequirementsBlock, readOwnedRequirements, writeOwnedRequirements, ownedForTitle, appendOwnedConstraints } from './requirements.js';
39
39
  import { detachUnsatisfiableRequirements, claimPendingRequirements, unclaimedPendingRequirements, formatReassignActions } from './owned-freeze-reassign.js';
40
40
  import { trackedSourceOracle } from './owned-freeze-conflict.js';
41
- import { thinkingForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED } from './child-runner.js';
41
+ import { thinkingForChild, runPhaseChild, runWithEmphasisRetry, prependHint, USER_CANCELLED, CommandTimeoutError, isFatalChildCause } from './child-runner.js';
42
42
  import { runResearchWorker, researchWorkerCacheHeading } from './research-worker.js';
43
43
  import { SessionUI } from '../remote/bridge.js';
44
44
  import { isYoloMode, yoloPickAutoAnswer } from './yolo.js';
@@ -301,7 +301,16 @@ export async function phaseVerifyTooling(deps, research) {
301
301
  try {
302
302
  verifyOutput = await runPhaseChild(deps, 'verify-tooling', 'read,bash', VERIFY_TOOLING_PROMPT(toolingList));
303
303
  }
304
- catch {
304
+ catch (e) {
305
+ if (isFatalChildCause(e))
306
+ throw e;
307
+ // The fallback ships the tooling list UNVERIFIED, which is the right
308
+ // degrade for a child that merely failed. A hung command is different: it
309
+ // cost the ceiling on every strike and says the SPEC named something
310
+ // unbounded, so it is the one cause worth a trail line rather than silence.
311
+ if (e instanceof CommandTimeoutError) {
312
+ deps.logDebug?.(`verify-tooling: ${e.message} — shipping the list unverified`);
313
+ }
305
314
  return replaceToolingWithVerified(research, commands);
306
315
  }
307
316
  const parsed = parseVerifyToolingOutput(verifyOutput);
@@ -761,7 +770,9 @@ export async function phaseAutoAnswer(deps, refined, research, question) {
761
770
  if (autoAnswerHasTag(text2))
762
771
  reasked = parseAutoAnswer(text2);
763
772
  }
764
- catch {
773
+ catch (e) {
774
+ if (isFatalChildCause(e))
775
+ throw e;
765
776
  reasked = null;
766
777
  }
767
778
  if (reasked === null
@@ -1024,7 +1035,9 @@ extraDefects) {
1024
1035
  // needs no file access.
1025
1036
  verdict = await runPhaseChild(deps, 'critique-triage', '', CRITIQUE_TRIAGE_PROMPT(spec, refined, qa, contractsBlock));
1026
1037
  }
1027
- catch {
1038
+ catch (e) {
1039
+ if (isFatalChildCause(e))
1040
+ throw e;
1028
1041
  verdict = null;
1029
1042
  }
1030
1043
  deps.recordSubStep?.('triage', Date.now() - tTriage);
@@ -78,7 +78,8 @@ export type FocusedResult = FocusedFailure | FocusedAnswer;
78
78
  * `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
79
79
  *
80
80
  * Never retries — a re-ask of a deterministic extraction over unchanged content is a second
81
- * bill for the same answer. Exactly one child is spawned per call.
81
+ * bill for the same answer. Exactly one child is spawned per call, on every path including
82
+ * the empty-output failure below.
82
83
  */
83
84
  export declare function runFocusedExtraction(req: FocusedRequest): Promise<FocusedResult>;
84
85
  export {};
@@ -44,7 +44,8 @@ export const focusedChildArgs = (thinking = []) => [
44
44
  * `<answer>`/`<excerpt>` and verify the excerpt against `verifyAgainst`.
45
45
  *
46
46
  * Never retries — a re-ask of a deterministic extraction over unchanged content is a second
47
- * bill for the same answer. Exactly one child is spawned per call.
47
+ * bill for the same answer. Exactly one child is spawned per call, on every path including
48
+ * the empty-output failure below.
48
49
  */
49
50
  export async function runFocusedExtraction(req) {
50
51
  const spawn = req.spawn ?? defaultSpawn;
@@ -59,6 +60,16 @@ export async function runFocusedExtraction(req) {
59
60
  const failure = formatChildFailure(child, req.abortedMessage);
60
61
  if (failure !== null)
61
62
  return { ok: false, failure, ...evidence };
63
+ // A dropped provider exits 0 with empty text, and TEXT mode never populates
64
+ // `modelError`. Returned as an empty answer it is MEMOISED: both caches keep
65
+ // it and re-serve a dead socket as a real answer for the rest of the run.
66
+ if (child.stdout.trim().length === 0) {
67
+ return {
68
+ ok: false,
69
+ failure: 'Worker exited 0 without writing anything — no answer to parse',
70
+ ...evidence
71
+ };
72
+ }
62
73
  const parsed = parseChildOutput(child.stdout);
63
74
  const excerptCheck = parsed.excerpt ? verifyExcerpt(parsed.excerpt, req.verifyAgainst) : undefined;
64
75
  return {
@@ -1,4 +1,6 @@
1
1
  import { type ContextSnapshot, type LoopHit, type SpawnFn } from '../shared/child-process.js';
2
+ import { type CommandKill } from '../shared/command-watchdog.js';
3
+ export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
2
4
  import { RESTART_ORDER } from './worker-kill.js';
3
5
  import { type WorkerGuardOverride, type WorkerGuardPolicy, type WorkerPolicyInputs, type WorkerProfileId } from './worker-profiles.js';
4
6
  /**
@@ -337,29 +339,6 @@ export interface RunWorkerResult {
337
339
  idleMs: number;
338
340
  };
339
341
  }
340
- /**
341
- * The per-command ceiling for attempt N, halving each time a hang recurs.
342
- *
343
- * The first attempt gets the full configured ceiling — a genuinely slow build or
344
- * test suite deserves it. But every hang-caused restart carries
345
- * commandTimeoutHint, which tells the model in as many words to bound its
346
- * command; a SECOND hang means it ignored an explicit instruction, and a third
347
- * means it ignored it twice. Giving a non-complying child the full ceiling again
348
- * makes the worst case three times the ceiling, resting entirely on the model
349
- * obeying prose. Halving bounds it at under twice the ceiling while costing a
350
- * complying child nothing.
351
- *
352
- * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
353
- * restart budget is shared with loop kills, and a child restarted for LOOPING
354
- * never received the bound-your-command hint, so its first hang still deserves
355
- * the full ceiling. Only a hang after a hang is defiance.
356
- *
357
- * Floored at 30s so repeated halving cannot shrink the ceiling to something no
358
- * real command could finish inside — but the floor is `min(base, 30s)`, never
359
- * above the configured ceiling, so a caller asking for 10s keeps 10s at every
360
- * hang count. A base of 0 or less disables the watchdog and stays 0.
361
- */
362
- export declare function commandCeilingForAttempt(baseMs: number, priorHangs: number): number;
363
342
  /**
364
343
  * Everything the restart ladder reads about one finished attempt, plus the
365
344
  * budgets it draws on. Assembled once per attempt so the rules below can be
@@ -438,12 +417,4 @@ interface RestartRule {
438
417
  * becoming visible in `restarts`.
439
418
  */
440
419
  export declare const RESTART_RULES: readonly RestartRule[];
441
- /** What the command watchdog recorded when it killed an attempt. */
442
- interface CommandKill {
443
- toolName: string;
444
- timeoutMs: number;
445
- /** The command line itself, when the tool carried one — quoted into the hint
446
- * so the fresh child knows which call it must not repeat unbounded. */
447
- detail?: string;
448
- }
449
420
  export declare function runWorker(input: RunWorkerInput): Promise<RunWorkerResult>;
@@ -1,11 +1,12 @@
1
1
  import { getPiInvocation } from '../shared/pi-invocation.js';
2
2
  import { runChildDefault } from '../shared/child-process.js';
3
- import { CommandWatchdog, commandTimeoutHint, realTimerDeps } from '../shared/command-watchdog.js';
3
+ import { commandCeilingForAttempt, commandTimeoutHint, commandWatch } from '../shared/command-watchdog.js';
4
+ export { commandCeilingForAttempt } from '../shared/command-watchdog.js';
4
5
  import { isGroundingRetrieval as isGrounding, workerChannel } from './worker-channels.js';
5
6
  import { childBaseArgs } from '../shared/child-extensions.js';
6
- import { LoopDetector } from '../task/loop-detector.js';
7
+ import { LoopDetector, MAX_LOOP_RESTARTS } from '../task/loop-detector.js';
7
8
  import { StallDetector, formatStallHint } from '../task/stall-detector.js';
8
- import { MAX_LOOP_RESTARTS, formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
9
+ import { formatLoopHint, isConnectionError, connectionRetryBackoffMs } from '../task/child-runner.js';
9
10
  import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
10
11
  import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
11
12
  import { streamStallHint } from '../shared/stream-watchdog.js';
@@ -216,34 +217,6 @@ absoluteCeilingMs) {
216
217
  }
217
218
  };
218
219
  }
219
- /**
220
- * The per-command ceiling for attempt N, halving each time a hang recurs.
221
- *
222
- * The first attempt gets the full configured ceiling — a genuinely slow build or
223
- * test suite deserves it. But every hang-caused restart carries
224
- * commandTimeoutHint, which tells the model in as many words to bound its
225
- * command; a SECOND hang means it ignored an explicit instruction, and a third
226
- * means it ignored it twice. Giving a non-complying child the full ceiling again
227
- * makes the worst case three times the ceiling, resting entirely on the model
228
- * obeying prose. Halving bounds it at under twice the ceiling while costing a
229
- * complying child nothing.
230
- *
231
- * `priorHangs` counts watchdog kills specifically, NOT total restarts — the
232
- * restart budget is shared with loop kills, and a child restarted for LOOPING
233
- * never received the bound-your-command hint, so its first hang still deserves
234
- * the full ceiling. Only a hang after a hang is defiance.
235
- *
236
- * Floored at 30s so repeated halving cannot shrink the ceiling to something no
237
- * real command could finish inside — but the floor is `min(base, 30s)`, never
238
- * above the configured ceiling, so a caller asking for 10s keeps 10s at every
239
- * hang count. A base of 0 or less disables the watchdog and stays 0.
240
- */
241
- export function commandCeilingForAttempt(baseMs, priorHangs) {
242
- if (!(baseMs > 0))
243
- return 0;
244
- const floor = Math.min(baseMs, 30_000);
245
- return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
246
- }
247
220
  /**
248
221
  * The restart ladder, in precedence order. FIRST MATCH WINS.
249
222
  *
@@ -360,58 +333,6 @@ export const RESTART_RULES = [
360
333
  counters: { leak: true }
361
334
  }
362
335
  ];
363
- /**
364
- * Build the child-side command watchdog for ONE attempt: a per-tool-call timer
365
- * machine (shared with the main session) whose `onFire` aborts `signal`, which
366
- * runChild turns into a process-GROUP kill — reaping the hung command itself,
367
- * not just the pi child holding it.
368
- *
369
- * LIMIT: the group kill only reaches processes still IN the group. A hung command
370
- * that detached a daemon (setsid, nohup, a background dev server) leaves it
371
- * running, so the fresh attempt can hit a port the dead attempt's escapee still
372
- * holds. There is no cheap fix from here; the restart hint's "check current state"
373
- * line is the mitigation.
374
- *
375
- * Returns null when the watchdog is off, so the caller keeps the plain timeout
376
- * signal and no per-call bookkeeping happens at all.
377
- */
378
- function commandWatch(timeoutMs) {
379
- if (!(timeoutMs > 0))
380
- return null;
381
- const ctrl = new AbortController();
382
- // pi's toolCallId pairs start↔end. When it is absent (a fake stream in a
383
- // test, an older pi), fall back to one shared slot: tool executions in a
384
- // child are sequential, so a single slot is still correctly paired.
385
- const key = (id) => id ?? 'anon';
386
- const details = new Map();
387
- let killed;
388
- const watchdog = new CommandWatchdog({
389
- getTimeoutMs: () => timeoutMs,
390
- ...realTimerDeps,
391
- onFire: (toolCallId, toolName, ms) => {
392
- killed = {
393
- toolName,
394
- timeoutMs: ms,
395
- ...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
396
- };
397
- ctrl.abort();
398
- }
399
- });
400
- return {
401
- onStart: call => {
402
- const id = key(call.toolCallId);
403
- const args = call.args;
404
- if (typeof args?.command === 'string') {
405
- details.set(id, args.command.slice(0, 120));
406
- }
407
- watchdog.onStart(id, call.name);
408
- },
409
- onEnd: id => watchdog.onEnd(key(id)),
410
- killed: () => killed,
411
- signal: ctrl.signal,
412
- clear: () => watchdog.clearAll()
413
- };
414
- }
415
336
  export async function runWorker(input) {
416
337
  const tools = input.tools ?? DEFAULT_TOOLS;
417
338
  // `--mode json` makes pi emit structured events as they happen instead of
@@ -21,10 +21,10 @@
21
21
  *
22
22
  * - RepeatedCallGuard: "no identical search twice", for grep/find/ls — the
23
23
  * shapes the read guard cannot see, such as the same grep pattern re-run
24
- * against the same path. Keyed on `${toolName}\0${stableStringify(args)}`,
25
- * byte-identical to the key `LoopDetector.record` builds, so argument key
26
- * order never causes a miss and only an identical repeat trips. A different
27
- * pattern on the same file still passes.
24
+ * against the same path. Keyed with `loopKey`, the same identity
25
+ * `LoopDetector.record` uses, so argument key order never causes a miss and
26
+ * only an identical repeat trips. A different pattern on the same file still
27
+ * passes.
28
28
  *
29
29
  * Pure logic, no I/O — the extension does path resolution and tool routing.
30
30
  */
@@ -62,8 +62,8 @@ export declare class RepeatedCallGuard {
62
62
  /**
63
63
  * Record a `toolName` call with `args`. Returns a ReadBlock the second time
64
64
  * the same (toolName, stable-stringified args) pair is seen (and every time
65
- * after), else null on the first. Uses the LoopDetector's stableStringify so
66
- * argument key-order never causes a miss; only byte-identical calls collapse.
65
+ * after), else null on the first. Shares the LoopDetector's key, so argument
66
+ * key-order never causes a miss; only byte-identical calls collapse.
67
67
  */
68
68
  check(toolName: string, args: unknown): ReadBlock | null;
69
69
  }
@@ -21,14 +21,14 @@
21
21
  *
22
22
  * - RepeatedCallGuard: "no identical search twice", for grep/find/ls — the
23
23
  * shapes the read guard cannot see, such as the same grep pattern re-run
24
- * against the same path. Keyed on `${toolName}\0${stableStringify(args)}`,
25
- * byte-identical to the key `LoopDetector.record` builds, so argument key
26
- * order never causes a miss and only an identical repeat trips. A different
27
- * pattern on the same file still passes.
24
+ * against the same path. Keyed with `loopKey`, the same identity
25
+ * `LoopDetector.record` uses, so argument key order never causes a miss and
26
+ * only an identical repeat trips. A different pattern on the same file still
27
+ * passes.
28
28
  *
29
29
  * Pure logic, no I/O — the extension does path resolution and tool routing.
30
30
  */
31
- import { stableStringify } from '../task/loop-detector.js';
31
+ import { loopKey } from '../task/loop-detector.js';
32
32
  /**
33
33
  * The error text the model receives in place of the re-read's contents.
34
34
  *
@@ -105,11 +105,11 @@ export class RepeatedCallGuard {
105
105
  /**
106
106
  * Record a `toolName` call with `args`. Returns a ReadBlock the second time
107
107
  * the same (toolName, stable-stringified args) pair is seen (and every time
108
- * after), else null on the first. Uses the LoopDetector's stableStringify so
109
- * argument key-order never causes a miss; only byte-identical calls collapse.
108
+ * after), else null on the first. Shares the LoopDetector's key, so argument
109
+ * key-order never causes a miss; only byte-identical calls collapse.
110
110
  */
111
111
  check(toolName, args) {
112
- const key = `${toolName}\x00${stableStringify(args)}`;
112
+ const key = loopKey({ name: toolName, args });
113
113
  if (this.seen.has(key)) {
114
114
  return { block: true, reason: repeatedCallReason(toolName) };
115
115
  }
@@ -242,15 +242,15 @@ export declare const DEFAULT_LOOP_PROGRESS: {
242
242
  readonly limit: 8;
243
243
  readonly churnFactor: 2;
244
244
  };
245
- export type WorkerProfileId = 'research' | 'gate' | 'adhoc';
245
+ export type WorkerProfileId = 'research' | 'gate' | 'adhoc' | 'phase';
246
246
  /**
247
247
  * The facts a profile needs that are NOT policy: user config, and which of the
248
248
  * four research workers is the docs-capable one.
249
249
  */
250
250
  export interface WorkerPolicyInputs {
251
- /** gate: `config.requestTimeoutMs`. */
251
+ /** gate, phase: `config.requestTimeoutMs`. */
252
252
  commandTimeoutMs?: number;
253
- /** gate: `config.streamInactivityMs`. */
253
+ /** gate, phase: `config.streamInactivityMs`. */
254
254
  streamInactivityMs?: number;
255
255
  /** research: only `worker:apis` fans out, so only it can be scaled. */
256
256
  fanoutBounded?: boolean;
@@ -289,6 +289,14 @@ export declare const WORKER_PROFILES: {
289
289
  carryForward: false;
290
290
  };
291
291
  };
292
+ readonly phase: {
293
+ readonly id: "phase";
294
+ readonly why: string;
295
+ readonly resolve: (inputs: WorkerPolicyInputs) => {
296
+ guards: WorkerGuards;
297
+ carryForward: false;
298
+ };
299
+ };
292
300
  };
293
301
  /** Resolve one profile. The only way a caller should obtain a policy. */
294
302
  export declare function workerPolicy(id: WorkerProfileId, inputs?: WorkerPolicyInputs): WorkerGuardPolicy;
@@ -50,7 +50,10 @@
50
50
  * `RESTART_ORDER` and `FAILURE_ORDER` are untouched. This is a third view of
51
51
  * the same key, not a merge of the two orderings.
52
52
  */
53
- import { LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from '../task/child-runner.js';
53
+ // From loop-detector.ts, NOT child-runner.ts: child-runner reads this table, and
54
+ // these are evaluated at module top level below, so that import would close a
55
+ // cycle whose only symptom is a TDZ ReferenceError on import order.
56
+ import { LOOP_THRESHOLD, LOOP_WINDOW, MAX_LOOP_RESTARTS } from '../task/loop-detector.js';
54
57
  import { CONTEXT_CHURN_FACTOR, NO_PROGRESS_LIMIT } from '../task/stall-detector.js';
55
58
  import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs } from '../task/research-fanout-budget.js';
56
59
  /**
@@ -198,6 +201,35 @@ export const WORKER_PROFILES = {
198
201
  guards['stream-stall'] = inputs.streamInactivityMs ?? 0;
199
202
  return { guards, carryForward: false };
200
203
  }
204
+ },
205
+ phase: {
206
+ id: 'phase',
207
+ why: 'The spec-pipeline and planning children (runPhaseChild). Every call site '
208
+ + "but one passes `read` or `''`, and none of them EDITS, so the "
209
+ + 'path-revisit rule stays ON, unlike gate: re-reading one file here is '
210
+ + 'the thrash it was written for, not the job. '
211
+ + 'THE COMMAND WATCHDOG IS WHY THIS ROW EXISTS. verify-tooling holds '
212
+ + '`read,bash`, and a hung command there was unkillable: the stream '
213
+ + 'watchdog SUSPENDS for the duration of a tool call, the dead-backend '
214
+ + 'probe reads a reachable endpoint as alive, and both runaway detectors '
215
+ + 'wait on a result that never arrives. Only a user ESC could end it. '
216
+ + 'The wall clock stays OFF as PHASE_CHILD_TIMEOUT_MS decided for a FIXED '
217
+ + "cap; that does not settle research's progress-based ceiling. "
218
+ + 'PARTIALLY CONSUMED: runPhaseChild has its own strike loop and reads '
219
+ + 'only `command-timeout`, `stream-stall`, `stalled` and `loop`, so '
220
+ + 'setting `worker-timeout` or `connection-error` here does NOTHING.',
221
+ resolve: inputs => {
222
+ const guards = baseGuards();
223
+ // INERT for this profile — runPhaseChild never reads it. Zeroed anyway
224
+ // so the row cannot be mistaken for research's armed 240s cap.
225
+ guards['worker-timeout'] = { timeoutMs: 0, progressCeilingMs: null, fanout: null };
226
+ // Both ceilings are the user's own settings, as they are for gate: the
227
+ // number is theirs, the decision to arm it is this row's. 0 from a
228
+ // caller that hands none, so a harness cannot silently acquire a guard.
229
+ guards['command-timeout'] = inputs.commandTimeoutMs ?? 0;
230
+ guards['stream-stall'] = inputs.streamInactivityMs ?? 0;
231
+ return { guards, carryForward: false };
232
+ }
201
233
  // `as const satisfies`, not an annotation — the same reason RESTART_ORDER
202
234
  // gives: an annotation widens each row back to `WorkerProfile`, and the
203
235
  // `why` strings and literal ids stop being visible to a reader or a test.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.38.30",
3
+ "version": "0.38.31",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",