@mjasnikovs/pi-task 0.18.37 → 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/child-runner.js +18 -2
- 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
package/README.md
CHANGED
|
@@ -65,7 +65,7 @@ A whole plan — `/task-auto` splits it into an ordered task list and runs each
|
|
|
65
65
|
| `/task-auto <feature>` | Plan a feature into a task list and run each title through `/task` in order (resumable). |
|
|
66
66
|
| `/task-auto-resume` | Resume the active `/task-auto` run at the next unfinished task. |
|
|
67
67
|
| `/task-auto-cancel` | Stop the `/task-auto` loop after the current task (still resumable). |
|
|
68
|
-
| `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, and the extension whitelist for child sessions. |
|
|
68
|
+
| `/task-config` | Toggle pi-task settings in an editor dialog: remote server, compress reasoning, auto-commit, orientation, verify work, enforce guidelines, command timeout, stream watchdog, and the extension whitelist for child sessions. |
|
|
69
69
|
| `/remote` | Show the QR code & URLs for the web view (`/remote stop` to stop). Answer grill questions, start tasks, and watch progress from your phone. |
|
|
70
70
|
|
|
71
71
|
## The pipeline
|
|
@@ -180,6 +180,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
|
|
|
180
180
|
| **research cache** | on | Cache docs/search/fetch worker results for the duration of one `/task-auto` run so sibling tasks re-asking the same package/URL + query reuse the first pipeline's digest instead of re-fetching. Per-run isolated, external-only (project-source `.` lookups excluded), success-only. |
|
|
181
181
|
| **search provider** | Exa | Engine behind `pi-worker-search` and freshness/enrichment checks. **Exa** (default) and **DuckDuckGo** need no API key; **Brave** requires `BRAVE_SEARCH_API_KEY`. |
|
|
182
182
|
| **command timeout** | 15 min | Wall-clock ceiling on a **single** tool execution. Local models routinely run a command that never returns (a hung build, a dev server, a check with no timeout) and the run wedges until you abort by hand — pi's bash tool has an optional timeout with no default, so this is the missing one. One knob, two surfaces: in the main session the overrun call is cancelled (killing the tool's whole process tree) plus a reminder turn; in the verify/fix gate children the child is killed and re-spawned with a hint, halving the ceiling on repeat hangs. Choices: 5/10/15/30 min or **off** — off unguards both surfaces, gates included. |
|
|
183
|
+
| **stream watchdog** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
|
|
183
184
|
| **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
|
|
184
185
|
| **extension whitelist** | empty | Host `pi` extensions to load into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. `/task-config` enumerates the currently installed extensions as individual `ext: …` toggles; the list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
|
|
185
186
|
|
package/dist/config/config.d.ts
CHANGED
|
@@ -61,6 +61,26 @@ export interface PiTaskConfig {
|
|
|
61
61
|
* a true hang doesn't cost half an hour of dead time.
|
|
62
62
|
*/
|
|
63
63
|
requestTimeoutMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* Inactivity ceiling (ms) on the MODEL STREAM before the stream watchdog
|
|
66
|
+
* aborts the request (shared/stream-watchdog.ts). A hung or silently-dropped
|
|
67
|
+
* stream throws nothing, so neither the connection-error retry (needs a
|
|
68
|
+
* reported error) nor the command watchdog (covers tool calls only) nor the
|
|
69
|
+
* child stall guard (a reachable endpoint is proof of life) can see it — mx5
|
|
70
|
+
* run 14 lost ~2.9h to three such hangs while the model server stayed healthy.
|
|
71
|
+
*
|
|
72
|
+
* Measured as time since the LAST stream event of any kind, so a slow model
|
|
73
|
+
* emitting one token every 30s is never touched; only total silence counts.
|
|
74
|
+
* Suspended while a tool executes — that window is the command watchdog's.
|
|
75
|
+
*
|
|
76
|
+
* ONE knob, TWO surfaces: the MAIN session aborts the turn through the same
|
|
77
|
+
* plumbing the command watchdog uses and posts a resume reminder; a CHILD is
|
|
78
|
+
* killed and its result routed into the EXISTING connection-error retry.
|
|
79
|
+
* 0 = off, on both surfaces.
|
|
80
|
+
* DEFAULT 10 min: local prompt processing on a 32k context legitimately emits
|
|
81
|
+
* nothing for minutes, so a 60-120s ceiling would kill healthy long prompts.
|
|
82
|
+
*/
|
|
83
|
+
streamInactivityMs: number;
|
|
64
84
|
/**
|
|
65
85
|
* UNATTENDED AUTO-PICK (see task/yolo.ts): wherever pi-task would stop and ask,
|
|
66
86
|
* take the option it already marks RECOMMENDED, stamp the artifact `(YOLO)` so
|
|
@@ -91,6 +111,18 @@ export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
|
|
|
91
111
|
* one of the offered choices so the watchdog never arms on a nonsense value.
|
|
92
112
|
*/
|
|
93
113
|
export declare function sanitizeRequestTimeoutMs(value: unknown): number;
|
|
114
|
+
/**
|
|
115
|
+
* The stream-watchdog choices offered by /task-config, in cycle order. Every
|
|
116
|
+
* option is minutes, not seconds: the failure this guards costs hours, and the
|
|
117
|
+
* legitimate silence it must tolerate (local prompt processing) is minutes.
|
|
118
|
+
*/
|
|
119
|
+
export declare const STREAM_INACTIVITY_OPTIONS: ReadonlyArray<{
|
|
120
|
+
label: string;
|
|
121
|
+
ms: number;
|
|
122
|
+
}>;
|
|
123
|
+
/** Same pinning as {@link sanitizeRequestTimeoutMs}: a hand-edited value that is
|
|
124
|
+
* not one of the offered choices falls back to the default. */
|
|
125
|
+
export declare function sanitizeStreamInactivityMs(value: unknown): number;
|
|
94
126
|
/**
|
|
95
127
|
* A hand-edited config can hold anything; keep only string entries so a stray
|
|
96
128
|
* object/number can't reach the child argv as `-e [object Object]`.
|
package/dist/config/config.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as fsp from 'node:fs/promises';
|
|
|
3
3
|
import * as path from 'node:path';
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import { isSearchProvider } from '../workers/search-types.js';
|
|
6
|
+
import { DEFAULT_STREAM_INACTIVITY_MS } from '../shared/stream-watchdog.js';
|
|
6
7
|
/**
|
|
7
8
|
* The command-watchdog timeout choices offered by /task-config, newest-first in
|
|
8
9
|
* the cycle order the picker shows. The stored config value is the ms number;
|
|
@@ -25,6 +26,25 @@ export function sanitizeRequestTimeoutMs(value) {
|
|
|
25
26
|
value
|
|
26
27
|
: DEFAULT_REQUEST_TIMEOUT_MS;
|
|
27
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* The stream-watchdog choices offered by /task-config, in cycle order. Every
|
|
31
|
+
* option is minutes, not seconds: the failure this guards costs hours, and the
|
|
32
|
+
* legitimate silence it must tolerate (local prompt processing) is minutes.
|
|
33
|
+
*/
|
|
34
|
+
export const STREAM_INACTIVITY_OPTIONS = [
|
|
35
|
+
{ label: '5 min', ms: 5 * 60_000 },
|
|
36
|
+
{ label: '10 min', ms: DEFAULT_STREAM_INACTIVITY_MS },
|
|
37
|
+
{ label: '20 min', ms: 20 * 60_000 },
|
|
38
|
+
{ label: '30 min', ms: 30 * 60_000 },
|
|
39
|
+
{ label: 'off', ms: 0 }
|
|
40
|
+
];
|
|
41
|
+
/** Same pinning as {@link sanitizeRequestTimeoutMs}: a hand-edited value that is
|
|
42
|
+
* not one of the offered choices falls back to the default. */
|
|
43
|
+
export function sanitizeStreamInactivityMs(value) {
|
|
44
|
+
return STREAM_INACTIVITY_OPTIONS.some(o => o.ms === value) ?
|
|
45
|
+
value
|
|
46
|
+
: DEFAULT_STREAM_INACTIVITY_MS;
|
|
47
|
+
}
|
|
28
48
|
const DEFAULTS = {
|
|
29
49
|
remote: true,
|
|
30
50
|
compressReasoning: true,
|
|
@@ -39,6 +59,7 @@ const DEFAULTS = {
|
|
|
39
59
|
searchProvider: 'exa',
|
|
40
60
|
extensionWhitelist: [],
|
|
41
61
|
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
62
|
+
streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
|
|
42
63
|
// OFF: auto-answering is for unattended throwaway runs only.
|
|
43
64
|
yoloMode: false
|
|
44
65
|
};
|
|
@@ -69,6 +90,7 @@ if (!G.loaded) {
|
|
|
69
90
|
delete parsed.searchProvider;
|
|
70
91
|
parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
|
|
71
92
|
parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
|
|
93
|
+
parsed.streamInactivityMs = sanitizeStreamInactivityMs(parsed.streamInactivityMs);
|
|
72
94
|
// A hand-edited `"yoloMode": "false"` is a truthy string — it must not
|
|
73
95
|
// silently switch a watched run into unattended auto-pick. Only a real
|
|
74
96
|
// boolean counts; anything else falls back to the OFF default.
|
package/dist/config/register.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SettingsList, visibleWidth } from '@earendil-works/pi-tui';
|
|
2
2
|
import { registerBridgeCommand } from '../remote/bridge.js';
|
|
3
3
|
import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
|
|
4
|
-
import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig } from './config.js';
|
|
4
|
+
import { COMMAND_TIMEOUT_OPTIONS, getConfig, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
|
|
5
5
|
import { listInstalledExtensions } from './extension-list.js';
|
|
6
6
|
const CONFIG_TITLE = 'pi-task settings';
|
|
7
7
|
/**
|
|
@@ -108,6 +108,19 @@ const ITEMS = [
|
|
|
108
108
|
// Display human labels; the stored config value stays the ms number.
|
|
109
109
|
values: COMMAND_TIMEOUT_OPTIONS.map(o => o.label)
|
|
110
110
|
},
|
|
111
|
+
{
|
|
112
|
+
id: 'streamInactivityMs',
|
|
113
|
+
label: 'stream watchdog',
|
|
114
|
+
description: 'Abort and retry a model request whose stream goes SILENT for this long — a hung '
|
|
115
|
+
+ 'or dropped stream reports no error at all, so nothing else catches it (mx5 run '
|
|
116
|
+
+ '14 lost ~2.9h to three of them while the model server stayed healthy). Counts '
|
|
117
|
+
+ 'time since the last stream event of any kind, so a slow model that keeps '
|
|
118
|
+
+ 'emitting tokens is never touched, and it pauses while a tool runs. Keep it '
|
|
119
|
+
+ 'generous on local backends: prompt processing on a large context legitimately '
|
|
120
|
+
+ 'emits nothing for minutes. off disables it on both the main session and children',
|
|
121
|
+
// Display human labels; the stored config value stays the ms number.
|
|
122
|
+
values: STREAM_INACTIVITY_OPTIONS.map(o => o.label)
|
|
123
|
+
},
|
|
111
124
|
{
|
|
112
125
|
id: 'yoloMode',
|
|
113
126
|
label: 'yolo mode',
|
|
@@ -123,12 +136,18 @@ const ITEMS = [
|
|
|
123
136
|
function timeoutLabel(ms) {
|
|
124
137
|
return COMMAND_TIMEOUT_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
|
|
125
138
|
}
|
|
139
|
+
/** Human label for the stored stream-inactivity ms (falls back to the raw ms). */
|
|
140
|
+
function streamTimeoutLabel(ms) {
|
|
141
|
+
return STREAM_INACTIVITY_OPTIONS.find(o => o.ms === ms)?.label ?? `${ms}ms`;
|
|
142
|
+
}
|
|
126
143
|
/** What /task-config shows for a setting's current value. */
|
|
127
144
|
function displayValue(cfg, id, isEnum) {
|
|
128
145
|
if (id === 'searchProvider')
|
|
129
146
|
return SEARCH_PROVIDER_LABELS[cfg.searchProvider];
|
|
130
147
|
if (id === 'requestTimeoutMs')
|
|
131
148
|
return timeoutLabel(cfg.requestTimeoutMs);
|
|
149
|
+
if (id === 'streamInactivityMs')
|
|
150
|
+
return streamTimeoutLabel(cfg.streamInactivityMs);
|
|
132
151
|
if (isEnum)
|
|
133
152
|
return String(cfg[id]);
|
|
134
153
|
return cfg[id] ? 'on' : 'off';
|
|
@@ -206,6 +225,11 @@ async function handleTaskConfig(_args, ctx) {
|
|
|
206
225
|
if (opt)
|
|
207
226
|
cfg.requestTimeoutMs = opt.ms;
|
|
208
227
|
}
|
|
228
|
+
else if (id === 'streamInactivityMs') {
|
|
229
|
+
const opt = STREAM_INACTIVITY_OPTIONS.find(o => o.label === newValue);
|
|
230
|
+
if (opt)
|
|
231
|
+
cfg.streamInactivityMs = opt.ms;
|
|
232
|
+
}
|
|
209
233
|
else {
|
|
210
234
|
;
|
|
211
235
|
cfg[id] = newValue === 'on';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { registerWorkers } from './workers/index.js';
|
|
|
5
5
|
import { registerRemote } from './remote/register.js';
|
|
6
6
|
import { registerThinkingCompression } from './thinking/compress.js';
|
|
7
7
|
import { registerCommandWatchdog } from './task/command-watchdog.js';
|
|
8
|
+
import { registerStreamWatchdog } from './task/stream-watchdog.js';
|
|
8
9
|
export default function (pi) {
|
|
9
10
|
registerConfig(pi);
|
|
10
11
|
registerTask(pi);
|
|
@@ -13,4 +14,5 @@ export default function (pi) {
|
|
|
13
14
|
registerRemote(pi);
|
|
14
15
|
registerThinkingCompression(pi);
|
|
15
16
|
registerCommandWatchdog(pi);
|
|
17
|
+
registerStreamWatchdog(pi);
|
|
16
18
|
}
|
|
@@ -54,6 +54,17 @@ export interface ChildResult {
|
|
|
54
54
|
* and without the flag it would mislabel as a user cancel.
|
|
55
55
|
*/
|
|
56
56
|
stalled?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* true when the STREAM watchdog killed the child: no output at all for the
|
|
59
|
+
* configured inactivity window, regardless of whether the backend answers a
|
|
60
|
+
* probe. Distinct from `stalled`, which requires an UNREACHABLE endpoint —
|
|
61
|
+
* the run-14 hangs had a perfectly healthy server and a dead stream, so the
|
|
62
|
+
* probe path could never fire. Callers must check this BEFORE `aborted`
|
|
63
|
+
* (the kill sets aborted too) and route it into the connection-error retry.
|
|
64
|
+
*/
|
|
65
|
+
streamStalled?: {
|
|
66
|
+
idleMs: number;
|
|
67
|
+
};
|
|
57
68
|
}
|
|
58
69
|
export interface ToolCall {
|
|
59
70
|
name: string;
|
|
@@ -124,6 +135,15 @@ export interface RunChildJsonEventsOptions {
|
|
|
124
135
|
afterMs: number;
|
|
125
136
|
probe: () => Promise<boolean>;
|
|
126
137
|
};
|
|
138
|
+
/**
|
|
139
|
+
* Stream-inactivity ceiling in ms (shared/stream-watchdog.ts). Unlike `stall`
|
|
140
|
+
* this asks NOTHING of the backend: a stream that has produced no bytes for
|
|
141
|
+
* this long is dead whether or not the server answers a health probe, which
|
|
142
|
+
* is exactly the run-14 shape (server Up(healthy), stream silent for hours).
|
|
143
|
+
* Any stdout/stderr chunk resets it, so a slow model emitting one token every
|
|
144
|
+
* 30s is never killed. 0 / omitted = off.
|
|
145
|
+
*/
|
|
146
|
+
streamInactivityMs?: number;
|
|
127
147
|
}
|
|
128
148
|
export type RunChildOptions = RunChildTextOptions | RunChildJsonEventsOptions;
|
|
129
149
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
|
|
2
|
+
import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
|
|
2
3
|
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
3
4
|
export const KILL_GRACE_MS = 5000;
|
|
4
5
|
/** Base flags shared by all child pi invocations. */
|
|
@@ -248,7 +249,43 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
248
249
|
reapGroup('SIGKILL');
|
|
249
250
|
}, KILL_GRACE_MS);
|
|
250
251
|
};
|
|
251
|
-
|
|
252
|
+
// Stream watchdog (json-events children only): a silent stream is killed
|
|
253
|
+
// even when the backend is healthy — the case the probe-based stall guard
|
|
254
|
+
// below structurally cannot catch. Suspended for the duration of a tool
|
|
255
|
+
// call: a 12-minute build legitimately emits nothing, and that window is
|
|
256
|
+
// the COMMAND watchdog's to police, not this one's.
|
|
257
|
+
let streamStalledIdleMs;
|
|
258
|
+
const streamWatch = opts?.mode === 'json-events' && (opts.streamInactivityMs ?? 0) > 0 ?
|
|
259
|
+
new StreamWatchdog({
|
|
260
|
+
getTimeoutMs: () => opts.streamInactivityMs,
|
|
261
|
+
...realStreamTimerDeps,
|
|
262
|
+
onFire: idleMs => {
|
|
263
|
+
streamStalledIdleMs = idleMs;
|
|
264
|
+
killProc();
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
: null;
|
|
268
|
+
streamWatch?.start();
|
|
269
|
+
// The sink sees the child's parsed events; tool start/end are what tell
|
|
270
|
+
// the watchdog to pause and resume. Wrapping the caller's handlers keeps
|
|
271
|
+
// that wiring invisible to callers — and onToolResult must be present for
|
|
272
|
+
// the sink to emit tool_execution_end at all, so it is always supplied
|
|
273
|
+
// when the watchdog is on.
|
|
274
|
+
const sinkOpts = opts?.mode !== 'json-events' ? null
|
|
275
|
+
: streamWatch ?
|
|
276
|
+
{
|
|
277
|
+
...opts,
|
|
278
|
+
onToolCall: call => {
|
|
279
|
+
streamWatch.suspend();
|
|
280
|
+
return opts.onToolCall ? opts.onToolCall(call) : null;
|
|
281
|
+
},
|
|
282
|
+
onToolResult: r => {
|
|
283
|
+
streamWatch.resume();
|
|
284
|
+
opts.onToolResult?.(r);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
: opts;
|
|
288
|
+
const sink = sinkOpts ? new JsonEventSink(sinkOpts, killProc) : null;
|
|
252
289
|
// Dead-backend stall guard (json-events children only; see the option
|
|
253
290
|
// docs). Any output resets the window; a reachable probe also resets it
|
|
254
291
|
// so the next probe is a full window away, not every tick.
|
|
@@ -283,6 +320,7 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
283
320
|
let firstByteFired = false;
|
|
284
321
|
proc.stdout?.on('data', (d) => {
|
|
285
322
|
lastActivity = Date.now();
|
|
323
|
+
streamWatch?.note();
|
|
286
324
|
if (!firstByteFired) {
|
|
287
325
|
firstByteFired = true;
|
|
288
326
|
opts?.onFirstByte?.();
|
|
@@ -297,11 +335,13 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
297
335
|
});
|
|
298
336
|
proc.stderr?.on('data', (d) => {
|
|
299
337
|
lastActivity = Date.now();
|
|
338
|
+
streamWatch?.note();
|
|
300
339
|
stderr += d.toString();
|
|
301
340
|
});
|
|
302
341
|
proc.on('close', (code) => {
|
|
303
342
|
if (stallTimer)
|
|
304
343
|
clearInterval(stallTimer);
|
|
344
|
+
streamWatch?.stop();
|
|
305
345
|
// The child has exited, but anything it backgrounded (a dev server) may
|
|
306
346
|
// still hold its process group and a port — reap the group so the next
|
|
307
347
|
// gate's boot check does not collide with our own orphan. Best-effort:
|
|
@@ -320,12 +360,16 @@ export function runChild(spawn, invocation, cwd, signal, opts) {
|
|
|
320
360
|
aborted,
|
|
321
361
|
text,
|
|
322
362
|
modelError: sink?.modelError,
|
|
323
|
-
...(stalled ? { stalled: true } : {})
|
|
363
|
+
...(stalled ? { stalled: true } : {}),
|
|
364
|
+
...(streamStalledIdleMs !== undefined ?
|
|
365
|
+
{ streamStalled: { idleMs: streamStalledIdleMs } }
|
|
366
|
+
: {})
|
|
324
367
|
});
|
|
325
368
|
});
|
|
326
369
|
proc.on('error', () => {
|
|
327
370
|
if (stallTimer)
|
|
328
371
|
clearInterval(stallTimer);
|
|
372
|
+
streamWatch?.stop();
|
|
329
373
|
resolve({ stdout, stderr, exitCode: 1, aborted });
|
|
330
374
|
});
|
|
331
375
|
if (signal) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-stream watchdog — the inactivity machine for a stream that goes SILENT
|
|
3
|
+
* without erroring.
|
|
4
|
+
*
|
|
5
|
+
* WHY (mx5 run 14): three main-session implementation turns died mid-turn — the
|
|
6
|
+
* session jsonl's last event is an ordinary assistant message, then nothing,
|
|
7
|
+
* forever, while the model server stayed Up(healthy) the whole time. A hung or
|
|
8
|
+
* silently-dropped stream throws NOTHING, so:
|
|
9
|
+
* - the connection-error retry (child-runner.ts) never fires: it needs a
|
|
10
|
+
* thrown/reported ModelError,
|
|
11
|
+
* - the command watchdog never fires: it only covers TOOL executions,
|
|
12
|
+
* - the child stall guard never fires: it treats a reachable endpoint as proof
|
|
13
|
+
* of life, which it is — the endpoint was fine, the stream was not.
|
|
14
|
+
* Cost in run 14: ~2.9h of dead air awaiting manual restarts.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS MEASURES: time since the LAST stream event of ANY kind — text token,
|
|
17
|
+
* tool-call delta, thinking delta, provider response header. NOT wall-clock, and
|
|
18
|
+
* NOT "time to first token". One token every 30s is a working local model and
|
|
19
|
+
* must never be killed; zero events for the whole window is a hang.
|
|
20
|
+
*
|
|
21
|
+
* WHY THE DEFAULT IS GENEROUS: on a local llama-server a 32k-context prompt can
|
|
22
|
+
* spend many minutes in prompt processing emitting nothing at all. A 60-120s
|
|
23
|
+
* ceiling would kill every long prompt on local hardware. See
|
|
24
|
+
* DEFAULT_STREAM_INACTIVITY_MS.
|
|
25
|
+
*
|
|
26
|
+
* TWO SURFACES, one machine (same split as command-watchdog.ts):
|
|
27
|
+
* MAIN SESSION — task/stream-watchdog.ts arms it from pi's extension events and
|
|
28
|
+
* fires ctx.abort() through the SAME abort plumbing the command
|
|
29
|
+
* watchdog uses (noteWatchdogAbort → steerUntilDone), so there is
|
|
30
|
+
* exactly one abort channel, not two racing ones.
|
|
31
|
+
* CHILDREN — shared/child-process.ts arms it on the child's stdout/stderr
|
|
32
|
+
* chunks; a fire kills the child and the result carries
|
|
33
|
+
* `streamStalled`, which child-runner turns into a
|
|
34
|
+
* connection-class cause so the EXISTING retry/backoff path
|
|
35
|
+
* handles it (re-spawn from the last durable prompt, never a
|
|
36
|
+
* blind re-send of a half-executed turn).
|
|
37
|
+
*/
|
|
38
|
+
import type { TimerHandle } from './command-watchdog.js';
|
|
39
|
+
export type { TimerHandle };
|
|
40
|
+
/**
|
|
41
|
+
* Default inactivity ceiling: 10 minutes. Chosen from the constraint that a local
|
|
42
|
+
* model's first token can legitimately be many minutes away — the guard exists to
|
|
43
|
+
* turn hours of dead air into minutes, not to police slowness.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_STREAM_INACTIVITY_MS: number;
|
|
46
|
+
/**
|
|
47
|
+
* How often the machine checks the idle clock. A poll (rather than re-arming a
|
|
48
|
+
* timeout on every token) keeps cost O(1) per window instead of O(1) per token —
|
|
49
|
+
* a streaming turn emits thousands of events. Same idiom as the child stall guard.
|
|
50
|
+
*/
|
|
51
|
+
export declare function pollIntervalMs(timeoutMs: number): number;
|
|
52
|
+
export interface StreamWatchdogDeps {
|
|
53
|
+
/** The inactivity ceiling in ms, read when the watchdog arms. 0 (or any
|
|
54
|
+
* non-positive value) means the watchdog is off and never arms. */
|
|
55
|
+
getTimeoutMs: () => number;
|
|
56
|
+
now: () => number;
|
|
57
|
+
schedule: (fn: () => void, ms: number) => TimerHandle;
|
|
58
|
+
cancel: (handle: TimerHandle) => void;
|
|
59
|
+
/** Invoked once per arming when the stream has been silent for the ceiling. */
|
|
60
|
+
onFire: (idleMs: number, timeoutMs: number) => void;
|
|
61
|
+
}
|
|
62
|
+
export declare class StreamWatchdog {
|
|
63
|
+
private readonly deps;
|
|
64
|
+
private timer;
|
|
65
|
+
private lastEvent;
|
|
66
|
+
private armedMs;
|
|
67
|
+
/** True while a TOOL is executing: the model stream is legitimately idle then,
|
|
68
|
+
* and that window belongs to the command watchdog, not to this one. Without
|
|
69
|
+
* this, a 10-minute build would look identical to a hung stream. */
|
|
70
|
+
private suspended;
|
|
71
|
+
private fired;
|
|
72
|
+
constructor(deps: StreamWatchdogDeps);
|
|
73
|
+
/** Begin watching a model request. No-op when the watchdog is off or already armed. */
|
|
74
|
+
start(): void;
|
|
75
|
+
/** Any stream event of any kind: resets the idle clock. */
|
|
76
|
+
note(): void;
|
|
77
|
+
/** A tool started executing — pause the idle clock until it ends. */
|
|
78
|
+
suspend(): void;
|
|
79
|
+
/** A tool finished — the stream is expected to resume; restart the clock. */
|
|
80
|
+
resume(): void;
|
|
81
|
+
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
82
|
+
stop(): void;
|
|
83
|
+
/** @internal Exposed for the poll callback and tests. */
|
|
84
|
+
check(): void;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Real-clock poll deps, unref'd so a pending watchdog poll can never itself keep
|
|
88
|
+
* the process alive on exit. `schedule` returns a repeating interval — the
|
|
89
|
+
* machine cancels it on stop/fire.
|
|
90
|
+
*/
|
|
91
|
+
export declare const realStreamTimerDeps: Pick<StreamWatchdogDeps, 'now' | 'schedule' | 'cancel'>;
|
|
92
|
+
/**
|
|
93
|
+
* The cause string a CHILD's stream stall is reported as. Phrased so
|
|
94
|
+
* {@link isConnectionError} (child-runner.ts) matches it — the whole point is to
|
|
95
|
+
* route a silent hang into the retry path that already exists for a LOUD
|
|
96
|
+
* connection failure, rather than inventing a second one. Honest about who
|
|
97
|
+
* killed it: pi-task aborted the request, the provider did not report anything.
|
|
98
|
+
*/
|
|
99
|
+
export declare function streamStallCause(idleMs: number): string;
|
|
100
|
+
/**
|
|
101
|
+
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() ended the
|
|
102
|
+
* hung turn. Carries the shared WATCHDOG_CANCEL_MARKER so steerUntilDone
|
|
103
|
+
* recognises it as a watchdog recovery rather than a human ESC (see
|
|
104
|
+
* task/command-watchdog.ts) — one marker, one abort channel.
|
|
105
|
+
*
|
|
106
|
+
* Idempotent resume, not a re-send: the aborted turn's tool calls and their
|
|
107
|
+
* results are already in the transcript, so the model is told to CONTINUE from
|
|
108
|
+
* what is recorded. Re-issuing the turn wholesale would re-run tool calls that
|
|
109
|
+
* already ran.
|
|
110
|
+
*/
|
|
111
|
+
export declare function streamStallReminder(idleMs: number, marker: string): string;
|
|
112
|
+
/**
|
|
113
|
+
* CHILD restart hint for a re-spawned worker whose previous attempt was killed by
|
|
114
|
+
* the stream watchdog. The killed child is gone and never saw an error, so this
|
|
115
|
+
* states plainly what happened and, unlike the command-timeout hint, does NOT
|
|
116
|
+
* blame the model: a hung stream is an infrastructure fault, and telling the model
|
|
117
|
+
* to "be faster" would teach it to truncate its work for no reason.
|
|
118
|
+
*/
|
|
119
|
+
export declare function streamStallHint(idleMs: number): string;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-stream watchdog — the inactivity machine for a stream that goes SILENT
|
|
3
|
+
* without erroring.
|
|
4
|
+
*
|
|
5
|
+
* WHY (mx5 run 14): three main-session implementation turns died mid-turn — the
|
|
6
|
+
* session jsonl's last event is an ordinary assistant message, then nothing,
|
|
7
|
+
* forever, while the model server stayed Up(healthy) the whole time. A hung or
|
|
8
|
+
* silently-dropped stream throws NOTHING, so:
|
|
9
|
+
* - the connection-error retry (child-runner.ts) never fires: it needs a
|
|
10
|
+
* thrown/reported ModelError,
|
|
11
|
+
* - the command watchdog never fires: it only covers TOOL executions,
|
|
12
|
+
* - the child stall guard never fires: it treats a reachable endpoint as proof
|
|
13
|
+
* of life, which it is — the endpoint was fine, the stream was not.
|
|
14
|
+
* Cost in run 14: ~2.9h of dead air awaiting manual restarts.
|
|
15
|
+
*
|
|
16
|
+
* WHAT THIS MEASURES: time since the LAST stream event of ANY kind — text token,
|
|
17
|
+
* tool-call delta, thinking delta, provider response header. NOT wall-clock, and
|
|
18
|
+
* NOT "time to first token". One token every 30s is a working local model and
|
|
19
|
+
* must never be killed; zero events for the whole window is a hang.
|
|
20
|
+
*
|
|
21
|
+
* WHY THE DEFAULT IS GENEROUS: on a local llama-server a 32k-context prompt can
|
|
22
|
+
* spend many minutes in prompt processing emitting nothing at all. A 60-120s
|
|
23
|
+
* ceiling would kill every long prompt on local hardware. See
|
|
24
|
+
* DEFAULT_STREAM_INACTIVITY_MS.
|
|
25
|
+
*
|
|
26
|
+
* TWO SURFACES, one machine (same split as command-watchdog.ts):
|
|
27
|
+
* MAIN SESSION — task/stream-watchdog.ts arms it from pi's extension events and
|
|
28
|
+
* fires ctx.abort() through the SAME abort plumbing the command
|
|
29
|
+
* watchdog uses (noteWatchdogAbort → steerUntilDone), so there is
|
|
30
|
+
* exactly one abort channel, not two racing ones.
|
|
31
|
+
* CHILDREN — shared/child-process.ts arms it on the child's stdout/stderr
|
|
32
|
+
* chunks; a fire kills the child and the result carries
|
|
33
|
+
* `streamStalled`, which child-runner turns into a
|
|
34
|
+
* connection-class cause so the EXISTING retry/backoff path
|
|
35
|
+
* handles it (re-spawn from the last durable prompt, never a
|
|
36
|
+
* blind re-send of a half-executed turn).
|
|
37
|
+
*/
|
|
38
|
+
/**
|
|
39
|
+
* Default inactivity ceiling: 10 minutes. Chosen from the constraint that a local
|
|
40
|
+
* model's first token can legitimately be many minutes away — the guard exists to
|
|
41
|
+
* turn hours of dead air into minutes, not to police slowness.
|
|
42
|
+
*/
|
|
43
|
+
export const DEFAULT_STREAM_INACTIVITY_MS = 10 * 60_000;
|
|
44
|
+
/** Whole minutes, floored at 1, for the human-facing window in every message. */
|
|
45
|
+
function minutes(ms) {
|
|
46
|
+
const mins = Math.max(1, Math.round(ms / 60_000));
|
|
47
|
+
return `${mins} minute${mins === 1 ? '' : 's'}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How often the machine checks the idle clock. A poll (rather than re-arming a
|
|
51
|
+
* timeout on every token) keeps cost O(1) per window instead of O(1) per token —
|
|
52
|
+
* a streaming turn emits thousands of events. Same idiom as the child stall guard.
|
|
53
|
+
*/
|
|
54
|
+
export function pollIntervalMs(timeoutMs) {
|
|
55
|
+
return Math.max(50, Math.min(Math.floor(timeoutMs / 4), 30_000));
|
|
56
|
+
}
|
|
57
|
+
export class StreamWatchdog {
|
|
58
|
+
deps;
|
|
59
|
+
timer;
|
|
60
|
+
lastEvent = 0;
|
|
61
|
+
armedMs = 0;
|
|
62
|
+
/** True while a TOOL is executing: the model stream is legitimately idle then,
|
|
63
|
+
* and that window belongs to the command watchdog, not to this one. Without
|
|
64
|
+
* this, a 10-minute build would look identical to a hung stream. */
|
|
65
|
+
suspended = false;
|
|
66
|
+
fired = false;
|
|
67
|
+
constructor(deps) {
|
|
68
|
+
this.deps = deps;
|
|
69
|
+
}
|
|
70
|
+
/** Begin watching a model request. No-op when the watchdog is off or already armed. */
|
|
71
|
+
start() {
|
|
72
|
+
if (this.timer !== undefined) {
|
|
73
|
+
// Already watching — a new request inside the same agent loop just
|
|
74
|
+
// counts as activity rather than restarting the machine.
|
|
75
|
+
this.note();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const ms = this.deps.getTimeoutMs();
|
|
79
|
+
if (!(ms > 0))
|
|
80
|
+
return;
|
|
81
|
+
this.armedMs = ms;
|
|
82
|
+
this.fired = false;
|
|
83
|
+
this.suspended = false;
|
|
84
|
+
this.lastEvent = this.deps.now();
|
|
85
|
+
this.timer = this.deps.schedule(() => this.check(), pollIntervalMs(ms));
|
|
86
|
+
}
|
|
87
|
+
/** Any stream event of any kind: resets the idle clock. */
|
|
88
|
+
note() {
|
|
89
|
+
this.lastEvent = this.deps.now();
|
|
90
|
+
}
|
|
91
|
+
/** A tool started executing — pause the idle clock until it ends. */
|
|
92
|
+
suspend() {
|
|
93
|
+
this.suspended = true;
|
|
94
|
+
}
|
|
95
|
+
/** A tool finished — the stream is expected to resume; restart the clock. */
|
|
96
|
+
resume() {
|
|
97
|
+
this.suspended = false;
|
|
98
|
+
this.note();
|
|
99
|
+
}
|
|
100
|
+
/** Stop watching (turn/agent/session end, or child exit). Safe to call twice. */
|
|
101
|
+
stop() {
|
|
102
|
+
if (this.timer !== undefined)
|
|
103
|
+
this.deps.cancel(this.timer);
|
|
104
|
+
this.timer = undefined;
|
|
105
|
+
this.suspended = false;
|
|
106
|
+
}
|
|
107
|
+
/** @internal Exposed for the poll callback and tests. */
|
|
108
|
+
check() {
|
|
109
|
+
if (this.timer === undefined || this.fired || this.suspended)
|
|
110
|
+
return;
|
|
111
|
+
const idle = this.deps.now() - this.lastEvent;
|
|
112
|
+
if (idle < this.armedMs)
|
|
113
|
+
return;
|
|
114
|
+
this.fired = true;
|
|
115
|
+
const armed = this.armedMs;
|
|
116
|
+
this.stop();
|
|
117
|
+
this.deps.onFire(idle, armed);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Real-clock poll deps, unref'd so a pending watchdog poll can never itself keep
|
|
122
|
+
* the process alive on exit. `schedule` returns a repeating interval — the
|
|
123
|
+
* machine cancels it on stop/fire.
|
|
124
|
+
*/
|
|
125
|
+
export const realStreamTimerDeps = {
|
|
126
|
+
now: () => Date.now(),
|
|
127
|
+
schedule: (fn, ms) => {
|
|
128
|
+
const handle = setInterval(fn, ms);
|
|
129
|
+
if (typeof handle.unref === 'function') {
|
|
130
|
+
;
|
|
131
|
+
handle.unref();
|
|
132
|
+
}
|
|
133
|
+
return handle;
|
|
134
|
+
},
|
|
135
|
+
cancel: handle => clearInterval(handle)
|
|
136
|
+
};
|
|
137
|
+
/**
|
|
138
|
+
* The cause string a CHILD's stream stall is reported as. Phrased so
|
|
139
|
+
* {@link isConnectionError} (child-runner.ts) matches it — the whole point is to
|
|
140
|
+
* route a silent hang into the retry path that already exists for a LOUD
|
|
141
|
+
* connection failure, rather than inventing a second one. Honest about who
|
|
142
|
+
* killed it: pi-task aborted the request, the provider did not report anything.
|
|
143
|
+
*/
|
|
144
|
+
export function streamStallCause(idleMs) {
|
|
145
|
+
return (`model stream inactivity: no stream events for ${minutes(idleMs)} — `
|
|
146
|
+
+ `connection lost (aborted by pi-task's stream watchdog; the provider `
|
|
147
|
+
+ `reported no error)`);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* MAIN-SESSION reminder, delivered as a follow-up turn after ctx.abort() ended the
|
|
151
|
+
* hung turn. Carries the shared WATCHDOG_CANCEL_MARKER so steerUntilDone
|
|
152
|
+
* recognises it as a watchdog recovery rather than a human ESC (see
|
|
153
|
+
* task/command-watchdog.ts) — one marker, one abort channel.
|
|
154
|
+
*
|
|
155
|
+
* Idempotent resume, not a re-send: the aborted turn's tool calls and their
|
|
156
|
+
* results are already in the transcript, so the model is told to CONTINUE from
|
|
157
|
+
* what is recorded. Re-issuing the turn wholesale would re-run tool calls that
|
|
158
|
+
* already ran.
|
|
159
|
+
*/
|
|
160
|
+
export function streamStallReminder(idleMs, marker) {
|
|
161
|
+
return (`[SYSTEM] The model stream produced no events for ${minutes(idleMs)} — the response `
|
|
162
|
+
+ `appeared to hang, so the turn ${marker} `
|
|
163
|
+
+ `Nothing was reported as failed and no error was raised: the stream simply went `
|
|
164
|
+
+ `silent, so any work the turn had ALREADY completed (tool calls and their results) `
|
|
165
|
+
+ `is still recorded above and must NOT be repeated. Continue from that recorded `
|
|
166
|
+
+ `state: re-read anything you are unsure about, then carry on with the remaining `
|
|
167
|
+
+ `work. Do not restart the task from the beginning.`);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* CHILD restart hint for a re-spawned worker whose previous attempt was killed by
|
|
171
|
+
* the stream watchdog. The killed child is gone and never saw an error, so this
|
|
172
|
+
* states plainly what happened and, unlike the command-timeout hint, does NOT
|
|
173
|
+
* blame the model: a hung stream is an infrastructure fault, and telling the model
|
|
174
|
+
* to "be faster" would teach it to truncate its work for no reason.
|
|
175
|
+
*/
|
|
176
|
+
export function streamStallHint(idleMs) {
|
|
177
|
+
return (`[SYSTEM NOTE: Your previous attempt was aborted because the model stream stopped `
|
|
178
|
+
+ `producing output for ${minutes(idleMs)} — an infrastructure hang, not a mistake `
|
|
179
|
+
+ `you made. That attempt's conversation is gone, but any file edits or command side `
|
|
180
|
+
+ `effects it made are still in the working tree, so check the current state before `
|
|
181
|
+
+ `assuming files are untouched. Redo the work from here.]`);
|
|
182
|
+
}
|
|
@@ -12,6 +12,8 @@ import { childBaseArgs } from '../shared/child-extensions.js';
|
|
|
12
12
|
import { LoopDetector } from './loop-detector.js';
|
|
13
13
|
import { detectLeakedToolCall, leakedToolCallHint, MAX_LEAK_RETRIES } from '../shared/leaked-tool-call.js';
|
|
14
14
|
import { readSection, setTaskSection } from './task-io.js';
|
|
15
|
+
import { streamStallCause } from '../shared/stream-watchdog.js';
|
|
16
|
+
import { getConfig } from '../config/config.js';
|
|
15
17
|
// ─── Loop detection constants ────────────────────────────────────────────────
|
|
16
18
|
// Defined here (not in phases.ts) to avoid a circular dependency:
|
|
17
19
|
// phases.ts → child-runner.ts → phases.ts
|
|
@@ -77,6 +79,11 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
|
|
|
77
79
|
let loopHit;
|
|
78
80
|
const result = await runChildUnified(spawnFn ?? spawn, invocation, cwd, signal, {
|
|
79
81
|
mode: 'json-events',
|
|
82
|
+
// A hung model stream reports nothing at all, so without this the
|
|
83
|
+
// phase child waits forever (mx5 run 14: ~2.9h of dead air). The kill
|
|
84
|
+
// is reported below as a connection-class cause, which routes it into
|
|
85
|
+
// the retry/backoff path this file already has for a LOUD disconnect.
|
|
86
|
+
streamInactivityMs: getConfig().streamInactivityMs,
|
|
80
87
|
onLine,
|
|
81
88
|
onContextUsage,
|
|
82
89
|
onToolCall: call => {
|
|
@@ -95,12 +102,21 @@ export async function runChild(cwd, tools, prompt, signal, onLine, onContextUsag
|
|
|
95
102
|
// fails with the unhelpful "X child produced no output" — the raw
|
|
96
103
|
// stdout/stderr that might contain the real error is discarded.
|
|
97
104
|
const text = result.text || result.stdout.trim();
|
|
105
|
+
// The stream watchdog's kill leaves no provider error to report (that is the
|
|
106
|
+
// whole failure mode), so name it here rather than letting it surface as the
|
|
107
|
+
// meaningless "produced no output". Never overwrite a real reported cause.
|
|
108
|
+
const modelError = result.modelError
|
|
109
|
+
?? (result.streamStalled ? streamStallCause(result.streamStalled.idleMs) : undefined);
|
|
98
110
|
return {
|
|
99
111
|
text,
|
|
100
|
-
|
|
112
|
+
// WE killed this child, so its exit status describes our own SIGTERM, not
|
|
113
|
+
// the child's verdict. Report 0 and let `modelError` carry the cause —
|
|
114
|
+
// otherwise the wrappers' `exitCode !== 0` guard throws a bare "child
|
|
115
|
+
// failed" before the connection-error retry ever gets to look.
|
|
116
|
+
exitCode: result.streamStalled ? 0 : result.exitCode,
|
|
101
117
|
stderr: result.stderr.trim(),
|
|
102
118
|
loopHit,
|
|
103
|
-
modelError
|
|
119
|
+
modelError,
|
|
104
120
|
// A tool call the model wrote as text (wrong dialect) never executed and
|
|
105
121
|
// sailed past the structured-event guards above; flag it so the wrappers
|
|
106
122
|
// can re-prompt instead of accepting the unexecuted call. Only meaningful
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -391,6 +391,11 @@ export function buildGateDeps(params) {
|
|
|
391
391
|
// ceiling the main session uses, so one /task-config knob
|
|
392
392
|
// covers implementation and gates alike.
|
|
393
393
|
commandTimeoutMs: getConfig().requestTimeoutMs,
|
|
394
|
+
// Same reasoning one level up: a gate child with no
|
|
395
|
+
// wall-clock cap also needs the HUNG-STREAM bound, which
|
|
396
|
+
// the probe-based stall guard structurally cannot supply
|
|
397
|
+
// (a healthy endpoint reads as proof of life).
|
|
398
|
+
streamInactivityMs: getConfig().streamInactivityMs,
|
|
394
399
|
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
395
400
|
onLine: line => {
|
|
396
401
|
lastLine = line;
|
|
@@ -538,6 +543,9 @@ export function buildGateDeps(params) {
|
|
|
538
543
|
// bash — wired anyway so a future tool grant can't quietly
|
|
539
544
|
// re-open the hole.
|
|
540
545
|
commandTimeoutMs: getConfig().requestTimeoutMs,
|
|
546
|
+
// Unbounded wall clock here too — the hung-stream
|
|
547
|
+
// bound is the only thing that ends a dead stream.
|
|
548
|
+
streamInactivityMs: getConfig().streamInactivityMs,
|
|
541
549
|
// Exact-match loop guard only: pathThreshold Infinity
|
|
542
550
|
// disables the path-revisit heuristic, so revisiting one
|
|
543
551
|
// file (which IS this pass's job) never trips — only a
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
/**
|
|
3
|
+
* MAIN-SESSION adapter for the model-stream watchdog.
|
|
4
|
+
*
|
|
5
|
+
* WHY (mx5 run 14): three implementation turns died mid-turn — the session jsonl's
|
|
6
|
+
* last record is an ordinary assistant message, then silence forever, while the
|
|
7
|
+
* model container stayed Up(healthy). No error is ever thrown for this shape, so
|
|
8
|
+
* the connection-error retry (which needs a reported ModelError) cannot fire and
|
|
9
|
+
* the command watchdog, which only covers tool executions, never arms. The run sat
|
|
10
|
+
* dead for ~2.9h across the three until a human restarted it.
|
|
11
|
+
*
|
|
12
|
+
* HOW: pi's extension events ARE the stream. Any of them — a token delta, a
|
|
13
|
+
* thinking delta, a tool-call delta, the provider's response headers — resets the
|
|
14
|
+
* idle clock; only total silence for the configured window fires. On fire the turn
|
|
15
|
+
* is aborted and a follow-up user turn tells the model to CONTINUE from the
|
|
16
|
+
* transcript (its completed tool calls and results are already recorded, so a
|
|
17
|
+
* blind re-send would re-run them).
|
|
18
|
+
*
|
|
19
|
+
* ONE ABORT CHANNEL: the fire path goes through the command watchdog's existing
|
|
20
|
+
* {@link noteWatchdogAbort} flag and its WATCHDOG_CANCEL_MARKER, so
|
|
21
|
+
* steerUntilDone's already-fixed abort/steer race (b543d15) covers this watchdog
|
|
22
|
+
* too instead of racing a second, parallel abort mechanism.
|
|
23
|
+
*
|
|
24
|
+
* SUSPENDED DURING TOOLS: while a tool executes the model stream is legitimately
|
|
25
|
+
* idle — a 12-minute build emits nothing. That window belongs to the command
|
|
26
|
+
* watchdog (requestTimeoutMs); this one pauses between tool_execution_start and
|
|
27
|
+
* tool_execution_end so the two can never double-fire on the same silence.
|
|
28
|
+
*
|
|
29
|
+
* SCOPE: main session only. Children run `--no-extensions`, so their equivalent
|
|
30
|
+
* guard lives in runChild (shared/child-process.ts) and shares the same machine.
|
|
31
|
+
*/
|
|
32
|
+
export declare function registerStreamWatchdog(pi: ExtensionAPI): void;
|
|
@@ -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",
|