@mjasnikovs/pi-task 0.18.26 → 0.18.28
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/config/config.d.ts +11 -7
- package/dist/config/register.js +3 -1
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/child-process.js +4 -2
- package/dist/shared/command-watchdog.d.ts +95 -0
- package/dist/shared/command-watchdog.js +150 -0
- package/dist/task/api-synthesis.d.ts +48 -0
- package/dist/task/api-synthesis.js +155 -0
- package/dist/task/command-watchdog.d.ts +9 -43
- package/dist/task/command-watchdog.js +28 -72
- package/dist/task/enforce-guidelines.d.ts +4 -0
- package/dist/task/enforce-guidelines.js +9 -0
- package/dist/task/gate-deps.js +14 -0
- package/dist/task/phases.js +85 -13
- package/dist/task/prompts.js +3 -0
- package/dist/task/verify-quality.d.ts +26 -0
- package/dist/task/verify-quality.js +182 -0
- package/dist/workers/pi-worker-core.d.ts +55 -0
- package/dist/workers/pi-worker-core.js +133 -4
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
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
4
|
import { childBaseArgs } from '../shared/child-extensions.js';
|
|
4
5
|
import { LoopDetector } from '../task/loop-detector.js';
|
|
5
6
|
import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
|
|
@@ -32,7 +33,12 @@ const RESEARCH_WORKER_TIMEOUT_MS = 240_000;
|
|
|
32
33
|
* alive) just gets probed and waits on.
|
|
33
34
|
*/
|
|
34
35
|
const STALL_AFTER_MS = 180_000;
|
|
35
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Restart hint after a WHOLE-WORKER wall-clock timeout — distinct from both the
|
|
38
|
+
* loop hint and the per-command hint. This one diagnoses over-exploration, which
|
|
39
|
+
* is what the whole-worker cap actually catches. A single hung COMMAND is a
|
|
40
|
+
* different fault with a different fix, and gets commandTimeoutHint instead.
|
|
41
|
+
*/
|
|
36
42
|
const WORKER_TIMEOUT_HINT = '[SYSTEM NOTE: Your previous attempt ran out of time before answering — you '
|
|
37
43
|
+ 'were exploring too long. Be decisive: do the minimum reads/greps needed, '
|
|
38
44
|
+ 'then write your answer now. Do not re-explore ground you have already covered.]';
|
|
@@ -69,6 +75,85 @@ function workerTimeout(external, ms) {
|
|
|
69
75
|
}
|
|
70
76
|
};
|
|
71
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* The per-command ceiling for attempt N, halving each time a hang recurs.
|
|
80
|
+
*
|
|
81
|
+
* The first attempt gets the full configured ceiling — a genuinely slow build or
|
|
82
|
+
* test suite deserves it. But every hang-caused restart carries
|
|
83
|
+
* commandTimeoutHint, which tells the model in as many words to bound its
|
|
84
|
+
* command; a SECOND hang means it ignored an explicit instruction, and a third
|
|
85
|
+
* means it ignored it twice. Giving a non-complying child the full ceiling again
|
|
86
|
+
* would put the worst case at 3 × 15 min = 45 minutes of dead time, resting
|
|
87
|
+
* entirely on the model obeying prose. Halving bounds it at ~26 min while
|
|
88
|
+
* costing a complying child nothing.
|
|
89
|
+
*
|
|
90
|
+
* `priorHangs` counts watchdog kills specifically, NOT total restarts — the
|
|
91
|
+
* restart budget is shared with loop kills, and a child restarted for LOOPING
|
|
92
|
+
* never received the bound-your-command hint, so its first hang still deserves
|
|
93
|
+
* the full ceiling. Only a hang after a hang is defiance.
|
|
94
|
+
*
|
|
95
|
+
* Floored at 30s so repeated halving cannot shrink the ceiling to something no
|
|
96
|
+
* real command could finish inside — but never ABOVE the configured ceiling
|
|
97
|
+
* itself, or a caller asking for 10s would silently get 30.
|
|
98
|
+
*/
|
|
99
|
+
export function commandCeilingForAttempt(baseMs, priorHangs) {
|
|
100
|
+
if (!(baseMs > 0))
|
|
101
|
+
return 0;
|
|
102
|
+
const floor = Math.min(baseMs, 30_000);
|
|
103
|
+
return Math.max(floor, Math.round(baseMs / 2 ** priorHangs));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Build the child-side command watchdog for ONE attempt: a per-tool-call timer
|
|
107
|
+
* machine (shared with the main session) whose `onFire` aborts `signal`, which
|
|
108
|
+
* runChild turns into a process-GROUP kill — reaping the hung command itself,
|
|
109
|
+
* not just the pi child holding it.
|
|
110
|
+
*
|
|
111
|
+
* LIMIT: the group kill only reaches processes still IN the group. A hung
|
|
112
|
+
* command that detached a daemon (setsid/nohup dev server) leaves it running —
|
|
113
|
+
* the fresh attempt can then hit a port the dead attempt's escapee still holds
|
|
114
|
+
* (the run-9 orphan-dev-server → false-EADDRINUSE shape). No cheap fix from
|
|
115
|
+
* here; the restart hint's "check current state" line is the mitigation.
|
|
116
|
+
*
|
|
117
|
+
* Returns null when the watchdog is off, so the caller keeps the plain timeout
|
|
118
|
+
* signal and no per-call bookkeeping happens at all.
|
|
119
|
+
*/
|
|
120
|
+
function commandWatch(timeoutMs) {
|
|
121
|
+
if (!(timeoutMs > 0))
|
|
122
|
+
return null;
|
|
123
|
+
const ctrl = new AbortController();
|
|
124
|
+
// pi's toolCallId pairs start↔end. When it is absent (a fake stream in a
|
|
125
|
+
// test, an older pi), fall back to one shared slot: tool executions in a
|
|
126
|
+
// child are sequential, so a single slot is still correctly paired.
|
|
127
|
+
const key = (id) => id ?? 'anon';
|
|
128
|
+
const details = new Map();
|
|
129
|
+
let killed;
|
|
130
|
+
const watchdog = new CommandWatchdog({
|
|
131
|
+
getTimeoutMs: () => timeoutMs,
|
|
132
|
+
...realTimerDeps,
|
|
133
|
+
onFire: (toolCallId, toolName, ms) => {
|
|
134
|
+
killed = {
|
|
135
|
+
toolName,
|
|
136
|
+
timeoutMs: ms,
|
|
137
|
+
...(details.has(toolCallId) ? { detail: details.get(toolCallId) } : {})
|
|
138
|
+
};
|
|
139
|
+
ctrl.abort();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
onStart: call => {
|
|
144
|
+
const id = key(call.toolCallId);
|
|
145
|
+
const args = call.args;
|
|
146
|
+
if (typeof args?.command === 'string') {
|
|
147
|
+
details.set(id, args.command.slice(0, 120));
|
|
148
|
+
}
|
|
149
|
+
watchdog.onStart(id, call.name);
|
|
150
|
+
},
|
|
151
|
+
onEnd: id => watchdog.onEnd(key(id)),
|
|
152
|
+
killed: () => killed,
|
|
153
|
+
signal: ctrl.signal,
|
|
154
|
+
clear: () => watchdog.clearAll()
|
|
155
|
+
};
|
|
156
|
+
}
|
|
72
157
|
export async function runWorker(input) {
|
|
73
158
|
const tools = input.tools ?? DEFAULT_TOOLS;
|
|
74
159
|
const baseArgs = [...childBaseArgs(input.extensions ?? []), '--mode', 'json', '--tools', tools];
|
|
@@ -79,6 +164,10 @@ export async function runWorker(input) {
|
|
|
79
164
|
// hint up to MAX_LOOP_RESTARTS times before we give up. Leaked tool calls
|
|
80
165
|
// keep their own MAX_LEAK_RETRIES budget below — a different failure mode.
|
|
81
166
|
let restarts = 0;
|
|
167
|
+
// Watchdog kills specifically — drives the ceiling halving. Kept apart from
|
|
168
|
+
// `restarts` (the shared budget) so a loop-caused restart doesn't shorten
|
|
169
|
+
// the rope of a child that has never hung (see commandCeilingForAttempt).
|
|
170
|
+
let hangKills = 0;
|
|
82
171
|
let leakRetries = 0;
|
|
83
172
|
for (;;) {
|
|
84
173
|
const prompt = hint === null ? input.prompt : `${hint}\n\n${input.prompt}`;
|
|
@@ -101,9 +190,13 @@ export async function runWorker(input) {
|
|
|
101
190
|
// caller couldn't distinguish from a crash.
|
|
102
191
|
let loopHit;
|
|
103
192
|
const timeout = workerTimeout(input.signal, timeoutMs);
|
|
193
|
+
// Per-tool-call watchdog for this attempt (null when off). Its abort is
|
|
194
|
+
// OR'd with the worker timeout / external cancel into the child's signal.
|
|
195
|
+
const cmdWatch = commandWatch(commandCeilingForAttempt(input.commandTimeoutMs ?? 0, hangKills));
|
|
196
|
+
const childSignal = cmdWatch ? AbortSignal.any([timeout.signal, cmdWatch.signal]) : timeout.signal;
|
|
104
197
|
let result;
|
|
105
198
|
try {
|
|
106
|
-
result = await runChildDefault(invocation, input.cwd,
|
|
199
|
+
result = await runChildDefault(invocation, input.cwd, childSignal, {
|
|
107
200
|
mode: 'json-events',
|
|
108
201
|
...(input.stall === false ?
|
|
109
202
|
{}
|
|
@@ -116,6 +209,7 @@ export async function runWorker(input) {
|
|
|
116
209
|
}),
|
|
117
210
|
onFirstByte: () => (tFirstByte = Date.now()),
|
|
118
211
|
onToolCall: call => {
|
|
212
|
+
cmdWatch?.onStart(call);
|
|
119
213
|
if (!loopDetector)
|
|
120
214
|
return null;
|
|
121
215
|
const hit = loopDetector.record(call);
|
|
@@ -124,18 +218,28 @@ export async function runWorker(input) {
|
|
|
124
218
|
return hit;
|
|
125
219
|
},
|
|
126
220
|
onLine: input.onLine,
|
|
127
|
-
|
|
221
|
+
// Always wired when the watchdog is on — the sink only emits
|
|
222
|
+
// tool_execution_end if a handler exists, and without it every
|
|
223
|
+
// timer would stay armed and fire on a finished command.
|
|
224
|
+
onToolResult: cmdWatch ?
|
|
225
|
+
r => {
|
|
226
|
+
cmdWatch.onEnd(r.toolCallId);
|
|
227
|
+
input.onToolResult?.(r);
|
|
228
|
+
}
|
|
229
|
+
: input.onToolResult,
|
|
128
230
|
onContextUsage: input.onContextUsage
|
|
129
231
|
}, input.spawn);
|
|
130
232
|
}
|
|
131
233
|
finally {
|
|
132
234
|
timeout.cleanup();
|
|
235
|
+
cmdWatch?.clear();
|
|
133
236
|
}
|
|
134
237
|
const tEnd = Date.now();
|
|
135
238
|
const waitMs = tFirstByte === null ? tEnd - tStart : tFirstByte - tStart;
|
|
136
239
|
const workMs = tFirstByte === null ? 0 : tEnd - tFirstByte;
|
|
137
240
|
const text = result.text ?? '';
|
|
138
241
|
const timedOut = timeout.timedOut();
|
|
242
|
+
const commandKill = cmdWatch?.killed();
|
|
139
243
|
// A loop-kill gets the same restart-with-hint treatment every other phase
|
|
140
244
|
// already gets (runPhaseWithLoopGuard) — name the offending call so the
|
|
141
245
|
// re-spawn avoids it. Bounded by the shared restart budget.
|
|
@@ -144,6 +248,23 @@ export async function runWorker(input) {
|
|
|
144
248
|
restarts++;
|
|
145
249
|
continue;
|
|
146
250
|
}
|
|
251
|
+
// A hung COMMAND is restartable too, on the same budget, but checked
|
|
252
|
+
// before the whole-worker timeout because its hint is the specific one:
|
|
253
|
+
// bound the command. (The two can't be confused — a watchdog kill leaves
|
|
254
|
+
// timeout.timedOut() false, since that flag tracks only its own timer.)
|
|
255
|
+
if (commandKill && !loopHit && restarts < MAX_LOOP_RESTARTS) {
|
|
256
|
+
hint = commandTimeoutHint(commandKill.toolName, commandKill.timeoutMs, {
|
|
257
|
+
commandDetail: commandKill.detail,
|
|
258
|
+
// Nothing reverts the tree between attempts, so a child that can
|
|
259
|
+
// mutate it (edit/write, or bash side effects) must not be told
|
|
260
|
+
// its previous attempt left no trace. Same capability test the
|
|
261
|
+
// gate logger uses — decided by tools, not by phase.
|
|
262
|
+
editsMayPersist: /\b(?:edit|bash|write)\b/.test(tools)
|
|
263
|
+
});
|
|
264
|
+
restarts++;
|
|
265
|
+
hangKills++;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
147
268
|
// A wall-clock timeout (the backstop for varied thrash the exact-match
|
|
148
269
|
// detector misses) is also restartable, sharing the same budget. Skip when
|
|
149
270
|
// a loop also tripped — the loop hint above is more specific.
|
|
@@ -171,7 +292,15 @@ export async function runWorker(input) {
|
|
|
171
292
|
...(leaked ? { leakedToolCall: leaked } : {}),
|
|
172
293
|
...(loopHit ? { loopHit } : {}),
|
|
173
294
|
...(timedOut ? { timedOut: true } : {}),
|
|
174
|
-
...(result.stalled ? { stalled: true } : {})
|
|
295
|
+
...(result.stalled ? { stalled: true } : {}),
|
|
296
|
+
...(commandKill ?
|
|
297
|
+
{
|
|
298
|
+
commandTimedOut: {
|
|
299
|
+
toolName: commandKill.toolName,
|
|
300
|
+
timeoutMs: commandKill.timeoutMs
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
: {})
|
|
175
304
|
};
|
|
176
305
|
}
|
|
177
306
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.28",
|
|
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",
|