@mjasnikovs/pi-task 0.18.36 → 0.18.38
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/README.md +2 -1
- package/dist/config/config.d.ts +32 -0
- package/dist/config/config.js +22 -0
- package/dist/config/register.js +25 -1
- package/dist/index.js +2 -0
- package/dist/shared/child-process.d.ts +20 -0
- package/dist/shared/child-process.js +46 -2
- package/dist/shared/stream-watchdog.d.ts +119 -0
- package/dist/shared/stream-watchdog.js +182 -0
- package/dist/task/accept-debt.d.ts +17 -1
- package/dist/task/accept-debt.js +20 -1
- package/dist/task/auto-orchestrator.js +116 -28
- package/dist/task/child-runner.js +18 -2
- package/dist/task/final-gate-fix.d.ts +18 -2
- package/dist/task/final-gate-fix.js +19 -6
- package/dist/task/final-gate-progress.d.ts +67 -0
- package/dist/task/final-gate-progress.js +106 -0
- package/dist/task/gate-deps.js +8 -0
- package/dist/task/stream-watchdog.d.ts +32 -0
- package/dist/task/stream-watchdog.js +90 -0
- package/dist/workers/pi-worker-core.d.ts +20 -0
- package/dist/workers/pi-worker-core.js +14 -0
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { getConfig } from '../config/config.js';
|
|
2
|
+
import { realStreamTimerDeps, StreamWatchdog, streamStallReminder } from '../shared/stream-watchdog.js';
|
|
3
|
+
import { noteWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
4
|
+
/**
|
|
5
|
+
* MAIN-SESSION adapter for the model-stream watchdog.
|
|
6
|
+
*
|
|
7
|
+
* WHY (mx5 run 14): three implementation turns died mid-turn — the session jsonl's
|
|
8
|
+
* last record is an ordinary assistant message, then silence forever, while the
|
|
9
|
+
* model container stayed Up(healthy). No error is ever thrown for this shape, so
|
|
10
|
+
* the connection-error retry (which needs a reported ModelError) cannot fire and
|
|
11
|
+
* the command watchdog, which only covers tool executions, never arms. The run sat
|
|
12
|
+
* dead for ~2.9h across the three until a human restarted it.
|
|
13
|
+
*
|
|
14
|
+
* HOW: pi's extension events ARE the stream. Any of them — a token delta, a
|
|
15
|
+
* thinking delta, a tool-call delta, the provider's response headers — resets the
|
|
16
|
+
* idle clock; only total silence for the configured window fires. On fire the turn
|
|
17
|
+
* is aborted and a follow-up user turn tells the model to CONTINUE from the
|
|
18
|
+
* transcript (its completed tool calls and results are already recorded, so a
|
|
19
|
+
* blind re-send would re-run them).
|
|
20
|
+
*
|
|
21
|
+
* ONE ABORT CHANNEL: the fire path goes through the command watchdog's existing
|
|
22
|
+
* {@link noteWatchdogAbort} flag and its WATCHDOG_CANCEL_MARKER, so
|
|
23
|
+
* steerUntilDone's already-fixed abort/steer race (b543d15) covers this watchdog
|
|
24
|
+
* too instead of racing a second, parallel abort mechanism.
|
|
25
|
+
*
|
|
26
|
+
* SUSPENDED DURING TOOLS: while a tool executes the model stream is legitimately
|
|
27
|
+
* idle — a 12-minute build emits nothing. That window belongs to the command
|
|
28
|
+
* watchdog (requestTimeoutMs); this one pauses between tool_execution_start and
|
|
29
|
+
* tool_execution_end so the two can never double-fire on the same silence.
|
|
30
|
+
*
|
|
31
|
+
* SCOPE: main session only. Children run `--no-extensions`, so their equivalent
|
|
32
|
+
* guard lives in runChild (shared/child-process.ts) and shares the same machine.
|
|
33
|
+
*/
|
|
34
|
+
export function registerStreamWatchdog(pi) {
|
|
35
|
+
// The ctx whose abort() ends the in-flight turn, refreshed on every event so
|
|
36
|
+
// the fire (which happens outside any handler) aborts the CURRENT operation.
|
|
37
|
+
let liveCtx;
|
|
38
|
+
const watchdog = new StreamWatchdog({
|
|
39
|
+
getTimeoutMs: () => getConfig().streamInactivityMs,
|
|
40
|
+
...realStreamTimerDeps,
|
|
41
|
+
onFire: idleMs => {
|
|
42
|
+
const ctx = liveCtx;
|
|
43
|
+
liveCtx = undefined;
|
|
44
|
+
// Flag BEFORE the abort, exactly as the command watchdog does: the
|
|
45
|
+
// steer loop can otherwise observe the 'aborted' turn first and show
|
|
46
|
+
// a steering prompt to an empty room, wedging an unattended run.
|
|
47
|
+
if (ctx) {
|
|
48
|
+
noteWatchdogAbort();
|
|
49
|
+
ctx.abort();
|
|
50
|
+
}
|
|
51
|
+
pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
|
|
52
|
+
deliverAs: 'followUp'
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Any event proves the stream is alive. `arm` also (re)starts the machine, so
|
|
57
|
+
// a request that begins after a previous turn ended is watched again without
|
|
58
|
+
// needing a single canonical "request started" event.
|
|
59
|
+
const arm = (ctx) => {
|
|
60
|
+
if (ctx)
|
|
61
|
+
liveCtx = ctx;
|
|
62
|
+
watchdog.start();
|
|
63
|
+
watchdog.note();
|
|
64
|
+
};
|
|
65
|
+
pi.on('before_provider_request', (_e, ctx) => arm(ctx));
|
|
66
|
+
pi.on('after_provider_response', (_e, ctx) => arm(ctx));
|
|
67
|
+
pi.on('turn_start', (_e, ctx) => arm(ctx));
|
|
68
|
+
pi.on('message_start', (_e, ctx) => arm(ctx));
|
|
69
|
+
pi.on('message_update', (_e, ctx) => arm(ctx));
|
|
70
|
+
pi.on('message_end', (_e, ctx) => arm(ctx));
|
|
71
|
+
pi.on('tool_execution_start', (_e, ctx) => {
|
|
72
|
+
liveCtx = ctx;
|
|
73
|
+
watchdog.suspend();
|
|
74
|
+
});
|
|
75
|
+
pi.on('tool_execution_update', (_e, ctx) => {
|
|
76
|
+
liveCtx = ctx;
|
|
77
|
+
});
|
|
78
|
+
pi.on('tool_execution_end', (_e, ctx) => {
|
|
79
|
+
liveCtx = ctx;
|
|
80
|
+
watchdog.resume();
|
|
81
|
+
});
|
|
82
|
+
// Nothing is streaming between agent loops; stop so no timer can fire into an
|
|
83
|
+
// idle session (which would abort nothing and post a reminder to no one).
|
|
84
|
+
const stop = () => {
|
|
85
|
+
watchdog.stop();
|
|
86
|
+
liveCtx = undefined;
|
|
87
|
+
};
|
|
88
|
+
pi.on('agent_end', stop);
|
|
89
|
+
pi.on('session_shutdown', stop);
|
|
90
|
+
}
|
|
@@ -74,6 +74,16 @@ export interface RunWorkerInput {
|
|
|
74
74
|
afterMs?: number;
|
|
75
75
|
probe?: () => Promise<boolean>;
|
|
76
76
|
} | false;
|
|
77
|
+
/**
|
|
78
|
+
* Stream-inactivity ceiling in ms (shared/stream-watchdog.ts). The stall guard
|
|
79
|
+
* above cannot catch a HUNG stream on a HEALTHY backend — it reads a reachable
|
|
80
|
+
* endpoint as proof of life, which is exactly what run 14's three hangs looked
|
|
81
|
+
* like. This one asks nothing of the backend: no output for this long (with
|
|
82
|
+
* tool executions excluded) ⇒ kill and restart the attempt with
|
|
83
|
+
* {@link streamStallHint}, inside the same shared restart budget.
|
|
84
|
+
* 0 / omitted = off.
|
|
85
|
+
*/
|
|
86
|
+
streamInactivityMs?: number;
|
|
77
87
|
}
|
|
78
88
|
export interface RunWorkerResult {
|
|
79
89
|
text: string;
|
|
@@ -129,6 +139,16 @@ export interface RunWorkerResult {
|
|
|
129
139
|
toolName: string;
|
|
130
140
|
timeoutMs: number;
|
|
131
141
|
};
|
|
142
|
+
/**
|
|
143
|
+
* Set when the stream watchdog killed the worker's FINAL attempt: the model
|
|
144
|
+
* stream produced nothing for the configured window while no tool was running.
|
|
145
|
+
* Like loopHit/timedOut the text is partial — treat as a failure, and check it
|
|
146
|
+
* BEFORE `aborted` (the kill aborts too), or a hung backend is mislabeled a
|
|
147
|
+
* user cancel.
|
|
148
|
+
*/
|
|
149
|
+
streamStalled?: {
|
|
150
|
+
idleMs: number;
|
|
151
|
+
};
|
|
132
152
|
}
|
|
133
153
|
/**
|
|
134
154
|
* The per-command ceiling for attempt N, halving each time a hang recurs.
|
|
@@ -6,6 +6,7 @@ import { LoopDetector } from '../task/loop-detector.js';
|
|
|
6
6
|
import { LOOP_WINDOW, LOOP_THRESHOLD, MAX_LOOP_RESTARTS, formatLoopHint } from '../task/child-runner.js';
|
|
7
7
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
8
8
|
import { discoverModelEndpoints, probeModelEndpoints } from '../shared/model-endpoint.js';
|
|
9
|
+
import { streamStallHint } from '../shared/stream-watchdog.js';
|
|
9
10
|
// `--mode json` makes pi emit structured events as they happen instead of
|
|
10
11
|
// buffering the assistant text and flushing on exit. That matters for the
|
|
11
12
|
// wait/work timing split: in text mode the first stdout chunk only arrives at
|
|
@@ -207,6 +208,9 @@ export async function runWorker(input) {
|
|
|
207
208
|
?? (() => probeModelEndpoints(discoverModelEndpoints()))
|
|
208
209
|
}
|
|
209
210
|
}),
|
|
211
|
+
...(input.streamInactivityMs ?
|
|
212
|
+
{ streamInactivityMs: input.streamInactivityMs }
|
|
213
|
+
: {}),
|
|
210
214
|
onFirstByte: () => (tFirstByte = Date.now()),
|
|
211
215
|
onToolCall: call => {
|
|
212
216
|
cmdWatch?.onStart(call);
|
|
@@ -240,6 +244,7 @@ export async function runWorker(input) {
|
|
|
240
244
|
const text = result.text ?? '';
|
|
241
245
|
const timedOut = timeout.timedOut();
|
|
242
246
|
const commandKill = cmdWatch?.killed();
|
|
247
|
+
const streamStalled = result.streamStalled;
|
|
243
248
|
// A loop-kill gets the same restart-with-hint treatment every other phase
|
|
244
249
|
// already gets (runPhaseWithLoopGuard) — name the offending call so the
|
|
245
250
|
// re-spawn avoids it. Bounded by the shared restart budget.
|
|
@@ -265,6 +270,14 @@ export async function runWorker(input) {
|
|
|
265
270
|
hangKills++;
|
|
266
271
|
continue;
|
|
267
272
|
}
|
|
273
|
+
// A hung model stream is restartable on the same budget. Checked before
|
|
274
|
+
// the wall-clock timeout because it is the more specific diagnosis (and
|
|
275
|
+
// its hint does not blame the model: nothing it did caused the hang).
|
|
276
|
+
if (streamStalled && !loopHit && restarts < MAX_LOOP_RESTARTS) {
|
|
277
|
+
hint = streamStallHint(streamStalled.idleMs);
|
|
278
|
+
restarts++;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
268
281
|
// A wall-clock timeout (the backstop for varied thrash the exact-match
|
|
269
282
|
// detector misses) is also restartable, sharing the same budget. Skip when
|
|
270
283
|
// a loop also tripped — the loop hint above is more specific.
|
|
@@ -293,6 +306,7 @@ export async function runWorker(input) {
|
|
|
293
306
|
...(loopHit ? { loopHit } : {}),
|
|
294
307
|
...(timedOut ? { timedOut: true } : {}),
|
|
295
308
|
...(result.stalled ? { stalled: true } : {}),
|
|
309
|
+
...(streamStalled ? { streamStalled } : {}),
|
|
296
310
|
...(commandKill ?
|
|
297
311
|
{
|
|
298
312
|
commandTimedOut: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.38",
|
|
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",
|