@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
|
@@ -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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/task/phases.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
66
|
-
*
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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 {
|
|
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.
|
|
109
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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",
|