@mjasnikovs/pi-task 0.42.8 → 0.42.10
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/shared/leftovers.d.ts +4 -1
- package/dist/shared/leftovers.js +82 -13
- package/dist/task/command-watchdog.d.ts +9 -2
- package/dist/task/command-watchdog.js +34 -4
- package/dist/task/stale-ctx.d.ts +11 -0
- package/dist/task/stale-ctx.js +13 -0
- package/dist/task/stream-watchdog.js +25 -6
- package/package.json +1 -1
|
@@ -10,8 +10,11 @@ export interface Leftovers {
|
|
|
10
10
|
* `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
|
|
11
11
|
* `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
|
|
12
12
|
* windows runner).
|
|
13
|
+
*
|
|
14
|
+
* `find` is the token scan, injectable because losing a live leftover from it is
|
|
15
|
+
* what the reap has to survive — see the comment on the scan in `trackToken`.
|
|
13
16
|
*/
|
|
14
|
-
export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number): Leftovers;
|
|
17
|
+
export declare function trackLeftovers(platform: NodeJS.Platform, base: NodeJS.ProcessEnv, graceMs: number, find?: (marker: string) => number[]): Leftovers;
|
|
15
18
|
/**
|
|
16
19
|
* A System32 executable by absolute path, so neither PATH nor the working directory
|
|
17
20
|
* can supply another. `||`, not `??`: an empty SystemRoot would make it relative.
|
package/dist/shared/leftovers.js
CHANGED
|
@@ -25,12 +25,16 @@ const USER_BASH_ENV = 'PI_TASK_USER_BASH_ENV';
|
|
|
25
25
|
* `graceMs` is POSIX's, between SIGTERM and SIGKILL. win32 has no grace to give:
|
|
26
26
|
* `taskkill /F` returns once the tree is dead, and its port free (40 of 40 on the
|
|
27
27
|
* windows runner).
|
|
28
|
+
*
|
|
29
|
+
* `find` is the token scan, injectable because losing a live leftover from it is
|
|
30
|
+
* what the reap has to survive — see the comment on the scan in `trackToken`.
|
|
28
31
|
*/
|
|
29
|
-
export function trackLeftovers(platform, base, graceMs) {
|
|
32
|
+
export function trackLeftovers(platform, base, graceMs, find) {
|
|
30
33
|
if (platform === 'win32')
|
|
31
34
|
return trackShells(base);
|
|
32
|
-
if (platform === 'linux' || platform === 'darwin')
|
|
33
|
-
return trackToken(platform, base, graceMs);
|
|
35
|
+
if (platform === 'linux' || platform === 'darwin') {
|
|
36
|
+
return trackToken(platform, base, graceMs, find);
|
|
37
|
+
}
|
|
34
38
|
return { env: base, reap: () => Promise.resolve() };
|
|
35
39
|
}
|
|
36
40
|
/**
|
|
@@ -41,30 +45,46 @@ export function system32(...segments) {
|
|
|
41
45
|
return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', ...segments);
|
|
42
46
|
}
|
|
43
47
|
// ─── linux, darwin: the environment token ───────────────────────────────────
|
|
44
|
-
function trackToken(platform, base, graceMs) {
|
|
48
|
+
function trackToken(platform, base, graceMs, scan) {
|
|
45
49
|
const token = randomUUID();
|
|
46
50
|
const marker = `${LEFTOVER_TOKEN_ENV}=${token}`;
|
|
47
|
-
const
|
|
51
|
+
const defaultScan = (m) => platform === 'linux' ? linuxPidsWith(m) : pidsInPsTable(darwinPsTable(), m);
|
|
52
|
+
const find = () => (scan ?? defaultScan)(marker);
|
|
53
|
+
const endedOf = platform === 'linux' ? linuxEnded : darwinEnded;
|
|
48
54
|
return {
|
|
49
55
|
env: { ...base, [LEFTOVER_TOKEN_ENV]: token },
|
|
50
56
|
reap: () => new Promise(resolve => {
|
|
51
57
|
const started = performance.now();
|
|
52
58
|
let killed = false;
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
// the
|
|
56
|
-
//
|
|
57
|
-
//
|
|
59
|
+
// Discovery stays by token, so a pid recycled mid-reap is never
|
|
60
|
+
// signalled. Liveness cannot: a dying process releases its memory —
|
|
61
|
+
// and with it the token — while it still holds its ports, so the
|
|
62
|
+
// scan reads it as gone about 9 times in 10. Each pid found is
|
|
63
|
+
// pinned to its start time and followed in the process table until
|
|
64
|
+
// that entry is reaped.
|
|
65
|
+
const held = new Map();
|
|
66
|
+
const follow = () => {
|
|
67
|
+
for (const pid of find())
|
|
68
|
+
if (!held.has(pid))
|
|
69
|
+
held.set(pid, hold(platform, pid));
|
|
70
|
+
for (const [pid, h] of held)
|
|
71
|
+
if (endedOf(h))
|
|
72
|
+
held.delete(pid);
|
|
73
|
+
};
|
|
74
|
+
follow();
|
|
75
|
+
signalEach([...held.keys()], 'SIGTERM');
|
|
76
|
+
// Each pass waits as long as the scan before it took, so the wait
|
|
77
|
+
// costs half a core at most and no invented interval.
|
|
58
78
|
const poll = () => {
|
|
59
79
|
const scanStart = performance.now();
|
|
60
|
-
|
|
80
|
+
follow();
|
|
61
81
|
const waited = performance.now() - started;
|
|
62
82
|
// A process SIGKILL cannot end (uninterruptible sleep) gets one
|
|
63
83
|
// more grace, then the run goes on without it.
|
|
64
|
-
if (
|
|
84
|
+
if (held.size === 0 || waited >= 2 * graceMs)
|
|
65
85
|
return resolve();
|
|
66
86
|
if (!killed && waited >= graceMs) {
|
|
67
|
-
signalEach(
|
|
87
|
+
signalEach([...held.keys()], 'SIGKILL');
|
|
68
88
|
killed = true;
|
|
69
89
|
}
|
|
70
90
|
setTimeout(poll, performance.now() - scanStart).unref();
|
|
@@ -73,6 +93,55 @@ function trackToken(platform, base, graceMs) {
|
|
|
73
93
|
})
|
|
74
94
|
};
|
|
75
95
|
}
|
|
96
|
+
function hold(platform, pid) {
|
|
97
|
+
const startedAt = platform === 'linux' ? linuxStartTime(pid) : darwinField(pid, 'lstart=');
|
|
98
|
+
return startedAt === undefined ? { pid } : { pid, startedAt };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether the process `held` names has ended, zombies included: a zombie has already
|
|
102
|
+
* released its ports and only waits to be reaped. A pid whose start time no longer
|
|
103
|
+
* matches belongs to someone else, so the one we held is gone.
|
|
104
|
+
*/
|
|
105
|
+
function linuxEnded(held) {
|
|
106
|
+
const rest = linuxStatAfterName(held.pid);
|
|
107
|
+
if (rest === null)
|
|
108
|
+
return true;
|
|
109
|
+
if (held.startedAt !== undefined && startTimeIn(rest) !== held.startedAt)
|
|
110
|
+
return true;
|
|
111
|
+
return /^[ZX]/.test(rest);
|
|
112
|
+
}
|
|
113
|
+
function darwinEnded(held) {
|
|
114
|
+
const state = darwinField(held.pid, 'state=');
|
|
115
|
+
if (state === undefined)
|
|
116
|
+
return true;
|
|
117
|
+
if (held.startedAt !== undefined && darwinField(held.pid, 'lstart=') !== held.startedAt) {
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return state.startsWith('Z');
|
|
121
|
+
}
|
|
122
|
+
/** `/proc/<pid>/stat` from the state char on: the name before it can hold ') Z' itself. */
|
|
123
|
+
function linuxStatAfterName(pid) {
|
|
124
|
+
try {
|
|
125
|
+
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
126
|
+
return stat.slice(stat.lastIndexOf(')') + 2);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** starttime is stat field 22, and `rest` begins at field 3. */
|
|
133
|
+
function startTimeIn(rest) {
|
|
134
|
+
return rest.split(' ')[19];
|
|
135
|
+
}
|
|
136
|
+
function linuxStartTime(pid) {
|
|
137
|
+
const rest = linuxStatAfterName(pid);
|
|
138
|
+
return rest === null ? undefined : startTimeIn(rest);
|
|
139
|
+
}
|
|
140
|
+
function darwinField(pid, field) {
|
|
141
|
+
const r = spawnSync('/bin/ps', ['-o', field, '-p', String(pid)], { encoding: 'utf8' });
|
|
142
|
+
const out = r.stdout?.trim();
|
|
143
|
+
return out ? out : undefined;
|
|
144
|
+
}
|
|
76
145
|
/** Pids whose environment holds `marker`, read from `<procRoot>/<pid>/environ`. */
|
|
77
146
|
export function linuxPidsWith(marker, procRoot = '/proc') {
|
|
78
147
|
const inner = Buffer.from(`\0${marker}\0`);
|
|
@@ -32,8 +32,15 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
32
32
|
* and shares the same machine from shared/command-watchdog.ts.
|
|
33
33
|
*/
|
|
34
34
|
export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WATCHDOG_CANCEL_MARKER, type TimerHandle, type WatchdogDeps } from '../shared/command-watchdog.js';
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* @internal Set by onFire when it aborts a turn. Exported for the adapter and tests.
|
|
37
|
+
* Returns the previous value, which an abort that then fails must put back — the
|
|
38
|
+
* flag is shared by both watchdogs, so clearing it unconditionally would swallow a
|
|
39
|
+
* genuine abort's pending flag and leave the steer loop prompting an empty room.
|
|
40
|
+
*/
|
|
41
|
+
export declare function noteWatchdogAbort(): boolean;
|
|
42
|
+
/** @internal Put the flag back after a noted abort did not happen. */
|
|
43
|
+
export declare function restoreWatchdogAbort(was: boolean): void;
|
|
37
44
|
/** True exactly once per watchdog abort; clears the flag. */
|
|
38
45
|
export declare function consumeWatchdogAbort(): boolean;
|
|
39
46
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { SELF_BOUNDED_TOOLS } from '../config/tool-list.js';
|
|
3
3
|
import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/command-watchdog.js';
|
|
4
|
+
import { isStaleCtxError } from './stale-ctx.js';
|
|
4
5
|
/**
|
|
5
6
|
* MAIN-SESSION adapter for the command watchdog.
|
|
6
7
|
*
|
|
@@ -53,9 +54,20 @@ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WA
|
|
|
53
54
|
* prompt.
|
|
54
55
|
*/
|
|
55
56
|
let watchdogAbortPending = false;
|
|
56
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* @internal Set by onFire when it aborts a turn. Exported for the adapter and tests.
|
|
59
|
+
* Returns the previous value, which an abort that then fails must put back — the
|
|
60
|
+
* flag is shared by both watchdogs, so clearing it unconditionally would swallow a
|
|
61
|
+
* genuine abort's pending flag and leave the steer loop prompting an empty room.
|
|
62
|
+
*/
|
|
57
63
|
export function noteWatchdogAbort() {
|
|
64
|
+
const was = watchdogAbortPending;
|
|
58
65
|
watchdogAbortPending = true;
|
|
66
|
+
return was;
|
|
67
|
+
}
|
|
68
|
+
/** @internal Put the flag back after a noted abort did not happen. */
|
|
69
|
+
export function restoreWatchdogAbort(was) {
|
|
70
|
+
watchdogAbortPending = was;
|
|
59
71
|
}
|
|
60
72
|
/** True exactly once per watchdog abort; clears the flag. */
|
|
61
73
|
export function consumeWatchdogAbort() {
|
|
@@ -86,10 +98,28 @@ export function registerCommandWatchdog(pi) {
|
|
|
86
98
|
// to bound its next attempt. The flag must precede the abort so the
|
|
87
99
|
// steer loop can never observe the 'aborted' turn before the flag.
|
|
88
100
|
if (ctx) {
|
|
89
|
-
noteWatchdogAbort();
|
|
90
|
-
|
|
101
|
+
const wasPending = noteWatchdogAbort();
|
|
102
|
+
try {
|
|
103
|
+
ctx.abort();
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
restoreWatchdogAbort(wasPending); // no turn was aborted
|
|
107
|
+
// Timer fired after session replacement/reload: the captured ctx
|
|
108
|
+
// is stale by design (Pi invalidates it in AgentSession.dispose).
|
|
109
|
+
// Swallow only that guard; anything else keeps throwing, and no
|
|
110
|
+
// follow-up is posted into the replacement session.
|
|
111
|
+
if (!isStaleCtxError(err))
|
|
112
|
+
throw err;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (!isStaleCtxError(err))
|
|
121
|
+
throw err;
|
|
91
122
|
}
|
|
92
|
-
pi.sendUserMessage(reminderMessage(toolName, timeoutMs), { deliverAs: 'followUp' });
|
|
93
123
|
}
|
|
94
124
|
});
|
|
95
125
|
pi.on('tool_execution_start', (event, ctx) => {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi invalidates every captured ctx (and the extension's own `pi` handle) when a
|
|
3
|
+
* session is replaced or reloaded — AgentSession.dispose → ExtensionRunner.invalidate
|
|
4
|
+
* — and the guard throws from the next use. Code that fires from a TIMER rather than
|
|
5
|
+
* an event handler therefore has to expect it: the throw would otherwise escape the
|
|
6
|
+
* callback and take the host down as an uncaughtException.
|
|
7
|
+
*
|
|
8
|
+
* Matched on the message because the runtime throws a plain Error with no code or
|
|
9
|
+
* class to test. Only that one guard is swallowed; every other failure still throws.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isStaleCtxError(err: unknown): boolean;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi invalidates every captured ctx (and the extension's own `pi` handle) when a
|
|
3
|
+
* session is replaced or reloaded — AgentSession.dispose → ExtensionRunner.invalidate
|
|
4
|
+
* — and the guard throws from the next use. Code that fires from a TIMER rather than
|
|
5
|
+
* an event handler therefore has to expect it: the throw would otherwise escape the
|
|
6
|
+
* callback and take the host down as an uncaughtException.
|
|
7
|
+
*
|
|
8
|
+
* Matched on the message because the runtime throws a plain Error with no code or
|
|
9
|
+
* class to test. Only that one guard is swallowed; every other failure still throws.
|
|
10
|
+
*/
|
|
11
|
+
export function isStaleCtxError(err) {
|
|
12
|
+
return err instanceof Error && err.message.includes('stale after session');
|
|
13
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getConfig } from '../config/config.js';
|
|
2
2
|
import { realStreamTimerDeps, StreamWatchdog, streamStallReminder } from '../shared/stream-watchdog.js';
|
|
3
|
-
import { noteWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
3
|
+
import { noteWatchdogAbort, restoreWatchdogAbort, WATCHDOG_CANCEL_MARKER } from './command-watchdog.js';
|
|
4
|
+
import { isStaleCtxError } from './stale-ctx.js';
|
|
4
5
|
/**
|
|
5
6
|
* MAIN-SESSION adapter for the model-stream watchdog.
|
|
6
7
|
*
|
|
@@ -47,12 +48,30 @@ export function registerStreamWatchdog(pi) {
|
|
|
47
48
|
// steer loop can otherwise observe the 'aborted' turn first and show
|
|
48
49
|
// a steering prompt to an empty room, wedging an unattended run.
|
|
49
50
|
if (ctx) {
|
|
50
|
-
noteWatchdogAbort();
|
|
51
|
-
|
|
51
|
+
const wasPending = noteWatchdogAbort();
|
|
52
|
+
try {
|
|
53
|
+
ctx.abort();
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
restoreWatchdogAbort(wasPending); // no turn was aborted
|
|
57
|
+
// Timer fired after session replacement/reload: the captured ctx
|
|
58
|
+
// is stale by design (Pi invalidates it in AgentSession.dispose).
|
|
59
|
+
// Swallow only that guard; anything else keeps throwing, and no
|
|
60
|
+
// follow-up is posted into the replacement session.
|
|
61
|
+
if (!isStaleCtxError(err))
|
|
62
|
+
throw err;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
|
|
68
|
+
deliverAs: 'followUp'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
if (!isStaleCtxError(err))
|
|
73
|
+
throw err;
|
|
52
74
|
}
|
|
53
|
-
pi.sendUserMessage(streamStallReminder(idleMs, WATCHDOG_CANCEL_MARKER), {
|
|
54
|
-
deliverAs: 'followUp'
|
|
55
|
-
});
|
|
56
75
|
}
|
|
57
76
|
});
|
|
58
77
|
// Any event proves the stream is alive. `arm` also (re)starts the machine, so
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.10",
|
|
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",
|