@sebastianandreasson/pi-autonomous-agents 0.5.1 → 0.5.2
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 +1 -0
- package/docs/PI_SUPERVISOR.md +1 -0
- package/package.json +3 -3
- package/src/cli.mjs +49 -1
- package/src/pi-repo.mjs +92 -11
- package/src/pi-rpc-adapter.mjs +63 -1
- package/src/pi-supervisor.mjs +27 -4
package/README.md
CHANGED
|
@@ -232,6 +232,7 @@ Recent versions of the package isolate each run more aggressively:
|
|
|
232
232
|
- in-progress iteration state persisted before agent work starts
|
|
233
233
|
- stale run locks recovered when the owning PID is gone
|
|
234
234
|
- timeout cleanup kills the full spawned process group, not only the direct child
|
|
235
|
+
- parent-death watchers shut down orphaned supervisor and adapter layers instead of letting them continue under `PPID 1`
|
|
235
236
|
|
|
236
237
|
That is meant to prevent orphaned timed-out agents or concurrent supervisors from corrupting shared state.
|
|
237
238
|
|
package/docs/PI_SUPERVISOR.md
CHANGED
|
@@ -222,6 +222,7 @@ The built-in adapter mitigates obvious local loops by watching PI RPC tool event
|
|
|
222
222
|
- a soft `continue` can be sent after inactivity
|
|
223
223
|
- a separate tool-aware watchdog can tolerate long-running `bash` or browser work without treating the turn as dead
|
|
224
224
|
- a hard no-event timeout aborts a wedged turn instead of hanging indefinitely
|
|
225
|
+
- parent-loss shutdown tears down the owned supervisor/adapter/PI child tree instead of allowing orphaned background runs
|
|
225
226
|
|
|
226
227
|
Important: terminal streaming does not reset the heartbeat by itself. The watchdog keys off PI RPC events and active tool state, not raw shell output.
|
|
227
228
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sebastianandreasson/pi-autonomous-agents",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Portable unattended PI harness for developer/tester/visual-review loops.",
|
|
7
7
|
"license": "MIT",
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"pi-harness": "./src/cli.mjs"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-telemetry.test.mjs",
|
|
20
|
-
"test": "node --test test/pi-heartbeat.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-telemetry.test.mjs"
|
|
19
|
+
"check": "node --check src/cli.mjs && node --check src/pi-clear-history.mjs && node --check src/pi-client.mjs && node --check src/pi-config.mjs && node --check src/pi-flow.mjs && node --check src/pi-heartbeat.mjs && node --check src/pi-history.mjs && node --check src/pi-preflight.mjs && node --check src/pi-prompts.mjs && node --check src/pi-repo.mjs && node --check src/pi-report.mjs && node --check src/pi-rpc-adapter.mjs && node --check src/pi-supervisor.mjs && node --check src/pi-telemetry.mjs && node --check src/pi-visual-once.mjs && node --check src/pi-visual-review.mjs && node --check src/index.mjs && node --check test/pi-heartbeat.test.mjs && node --check test/pi-lifecycle.test.mjs && node --check test/pi-role-models.test.mjs && node --check test/pi-flow.test.mjs && node --check test/pi-history.test.mjs && node --check test/pi-prompts.test.mjs && node --check test/pi-preflight.test.mjs && node --check test/pi-repo.test.mjs && node --check test/pi-telemetry.test.mjs && node --check test/fixtures/fake-pi.mjs",
|
|
20
|
+
"test": "node --test test/pi-heartbeat.test.mjs test/pi-lifecycle.test.mjs test/pi-role-models.test.mjs test/pi-flow.test.mjs test/pi-history.test.mjs test/pi-prompts.test.mjs test/pi-preflight.test.mjs test/pi-repo.test.mjs test/pi-telemetry.test.mjs"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,11 @@ import path from 'node:path'
|
|
|
4
4
|
import { spawn } from 'node:child_process'
|
|
5
5
|
import process from 'node:process'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import {
|
|
8
|
+
registerOwnedChildProcess,
|
|
9
|
+
signalChildProcess,
|
|
10
|
+
watchParentProcess,
|
|
11
|
+
} from './pi-repo.mjs'
|
|
7
12
|
|
|
8
13
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|
9
14
|
|
|
@@ -36,10 +41,53 @@ function main() {
|
|
|
36
41
|
env: process.env,
|
|
37
42
|
stdio: 'inherit',
|
|
38
43
|
})
|
|
44
|
+
registerOwnedChildProcess(child)
|
|
45
|
+
|
|
46
|
+
let shuttingDown = false
|
|
47
|
+
let forceKillTimer = null
|
|
48
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
49
|
+
shutdown({
|
|
50
|
+
signal: 'SIGTERM',
|
|
51
|
+
exitCode: 1,
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
function shutdown({
|
|
56
|
+
signal,
|
|
57
|
+
exitCode,
|
|
58
|
+
}) {
|
|
59
|
+
if (shuttingDown) {
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
shuttingDown = true
|
|
64
|
+
stopWatchingParent()
|
|
65
|
+
signalChildProcess(child.pid, signal)
|
|
66
|
+
forceKillTimer = setTimeout(() => {
|
|
67
|
+
signalChildProcess(child.pid, 'SIGKILL')
|
|
68
|
+
}, 1000)
|
|
69
|
+
if (typeof forceKillTimer.unref === 'function') {
|
|
70
|
+
forceKillTimer.unref()
|
|
71
|
+
}
|
|
72
|
+
process.exitCode = exitCode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
76
|
+
process.on(signal, () => {
|
|
77
|
+
shutdown({
|
|
78
|
+
signal,
|
|
79
|
+
exitCode: 128,
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
39
83
|
|
|
40
84
|
child.on('exit', (code, signal) => {
|
|
85
|
+
stopWatchingParent()
|
|
86
|
+
if (forceKillTimer) {
|
|
87
|
+
clearTimeout(forceKillTimer)
|
|
88
|
+
}
|
|
41
89
|
if (signal) {
|
|
42
|
-
process.
|
|
90
|
+
process.exitCode = 128
|
|
43
91
|
return
|
|
44
92
|
}
|
|
45
93
|
process.exitCode = code ?? 1
|
package/src/pi-repo.mjs
CHANGED
|
@@ -114,6 +114,57 @@ export function isProcessRunning(pid) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
const ownedChildren = new Map()
|
|
118
|
+
|
|
119
|
+
export function registerOwnedChildProcess(child, options = {}) {
|
|
120
|
+
const pid = normalizePid(child?.pid)
|
|
121
|
+
if (pid <= 0) {
|
|
122
|
+
return () => {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const entry = {
|
|
126
|
+
useProcessGroup: options.useProcessGroup === true && process.platform !== 'win32',
|
|
127
|
+
}
|
|
128
|
+
ownedChildren.set(pid, entry)
|
|
129
|
+
|
|
130
|
+
const unregister = () => {
|
|
131
|
+
ownedChildren.delete(pid)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof child?.once === 'function') {
|
|
135
|
+
child.once('exit', unregister)
|
|
136
|
+
child.once('close', unregister)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return unregister
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function signalChildProcess(pid, signal, options = {}) {
|
|
143
|
+
const normalizedPid = normalizePid(pid)
|
|
144
|
+
if (normalizedPid <= 0) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (options.useProcessGroup === true && process.platform !== 'win32') {
|
|
150
|
+
process.kill(-normalizedPid, signal)
|
|
151
|
+
} else {
|
|
152
|
+
process.kill(normalizedPid, signal)
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
} catch {
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function signalOwnedChildProcesses(signal) {
|
|
161
|
+
let handled = false
|
|
162
|
+
for (const [pid, entry] of [...ownedChildren.entries()]) {
|
|
163
|
+
handled = signalChildProcess(pid, signal, entry) || handled
|
|
164
|
+
}
|
|
165
|
+
return handled
|
|
166
|
+
}
|
|
167
|
+
|
|
117
168
|
export async function readJsonFile(filePath, fallback = null) {
|
|
118
169
|
try {
|
|
119
170
|
const raw = await fs.readFile(filePath, 'utf8')
|
|
@@ -211,20 +262,45 @@ export async function releaseRunLock(lockFile, runId) {
|
|
|
211
262
|
}
|
|
212
263
|
|
|
213
264
|
export function signalProcessTree(pid, signal) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
265
|
+
return signalChildProcess(pid, signal, { useProcessGroup: true })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function watchParentProcess(onParentExit, options = {}) {
|
|
269
|
+
const expectedParentPid = normalizePid(options.parentPid ?? process.ppid)
|
|
270
|
+
if (expectedParentPid <= 0 || typeof onParentExit !== 'function') {
|
|
271
|
+
return () => {}
|
|
217
272
|
}
|
|
218
273
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
274
|
+
let active = true
|
|
275
|
+
const intervalMs = Number.isFinite(Number(options.intervalMs))
|
|
276
|
+
? Math.max(100, Number(options.intervalMs))
|
|
277
|
+
: 1000
|
|
278
|
+
|
|
279
|
+
const interval = setInterval(() => {
|
|
280
|
+
if (!active) {
|
|
281
|
+
return
|
|
224
282
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
283
|
+
|
|
284
|
+
const currentParentPid = normalizePid(process.ppid)
|
|
285
|
+
if (currentParentPid === expectedParentPid && currentParentPid > 1) {
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
active = false
|
|
290
|
+
clearInterval(interval)
|
|
291
|
+
onParentExit({
|
|
292
|
+
expectedParentPid,
|
|
293
|
+
currentParentPid,
|
|
294
|
+
})
|
|
295
|
+
}, intervalMs)
|
|
296
|
+
|
|
297
|
+
if (typeof interval.unref === 'function') {
|
|
298
|
+
interval.unref()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return () => {
|
|
302
|
+
active = false
|
|
303
|
+
clearInterval(interval)
|
|
228
304
|
}
|
|
229
305
|
}
|
|
230
306
|
|
|
@@ -474,6 +550,9 @@ export async function runShellCommand({
|
|
|
474
550
|
detached: process.platform !== 'win32',
|
|
475
551
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
476
552
|
})
|
|
553
|
+
const unregisterChild = registerOwnedChildProcess(child, {
|
|
554
|
+
useProcessGroup: process.platform !== 'win32',
|
|
555
|
+
})
|
|
477
556
|
|
|
478
557
|
let stdout = ''
|
|
479
558
|
let stderr = ''
|
|
@@ -506,6 +585,7 @@ export async function runShellCommand({
|
|
|
506
585
|
})
|
|
507
586
|
|
|
508
587
|
child.on('error', (error) => {
|
|
588
|
+
unregisterChild()
|
|
509
589
|
if (killTimer) {
|
|
510
590
|
clearTimeout(killTimer)
|
|
511
591
|
}
|
|
@@ -524,6 +604,7 @@ export async function runShellCommand({
|
|
|
524
604
|
})
|
|
525
605
|
|
|
526
606
|
child.on('close', (code) => {
|
|
607
|
+
unregisterChild()
|
|
527
608
|
if (killTimer) {
|
|
528
609
|
clearTimeout(killTimer)
|
|
529
610
|
}
|
package/src/pi-rpc-adapter.mjs
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
getHeartbeatDecision,
|
|
11
11
|
resolveHeartbeatConfig,
|
|
12
12
|
} from './pi-heartbeat.mjs'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
registerOwnedChildProcess,
|
|
15
|
+
signalOwnedChildProcesses,
|
|
16
|
+
signalProcessTree,
|
|
17
|
+
watchParentProcess,
|
|
18
|
+
} from './pi-repo.mjs'
|
|
14
19
|
|
|
15
20
|
function createJsonlReader(stream, onLine) {
|
|
16
21
|
const rl = createInterface({ input: stream })
|
|
@@ -155,6 +160,9 @@ async function run() {
|
|
|
155
160
|
detached: process.platform !== 'win32',
|
|
156
161
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
157
162
|
})
|
|
163
|
+
registerOwnedChildProcess(child, {
|
|
164
|
+
useProcessGroup: process.platform !== 'win32',
|
|
165
|
+
})
|
|
158
166
|
|
|
159
167
|
let stderr = ''
|
|
160
168
|
const events = []
|
|
@@ -197,6 +205,36 @@ async function run() {
|
|
|
197
205
|
let continueAttempted = false
|
|
198
206
|
let continueAccepted = false
|
|
199
207
|
let continueRejected = false
|
|
208
|
+
let shutdownRequested = false
|
|
209
|
+
let shutdownReason = ''
|
|
210
|
+
let shutdownEscalationTimer = null
|
|
211
|
+
|
|
212
|
+
const requestShutdown = (reason, signal = 'SIGTERM') => {
|
|
213
|
+
if (shutdownRequested) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
shutdownRequested = true
|
|
218
|
+
shutdownReason = String(reason ?? 'adapter_shutdown')
|
|
219
|
+
closeAssistantLine()
|
|
220
|
+
signalOwnedChildProcesses(signal)
|
|
221
|
+
shutdownEscalationTimer = setTimeout(() => {
|
|
222
|
+
signalOwnedChildProcesses('SIGKILL')
|
|
223
|
+
}, 1000)
|
|
224
|
+
if (typeof shutdownEscalationTimer.unref === 'function') {
|
|
225
|
+
shutdownEscalationTimer.unref()
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
230
|
+
requestShutdown('parent_exit', 'SIGTERM')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
234
|
+
process.on(signal, () => {
|
|
235
|
+
requestShutdown(signal, signal)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
200
238
|
|
|
201
239
|
const writeLive = (text) => {
|
|
202
240
|
if (!streamTerminal) {
|
|
@@ -460,6 +498,26 @@ async function run() {
|
|
|
460
498
|
|
|
461
499
|
await waitForAgentEnd()
|
|
462
500
|
|
|
501
|
+
if (shutdownRequested) {
|
|
502
|
+
console.log(JSON.stringify({
|
|
503
|
+
sessionId: request.sessionId ?? '',
|
|
504
|
+
sessionFile: request.sessionFile ?? '',
|
|
505
|
+
status: 'failed',
|
|
506
|
+
output: '',
|
|
507
|
+
notes: shutdownReason,
|
|
508
|
+
role: '',
|
|
509
|
+
model: requestedModel,
|
|
510
|
+
toolCalls: 0,
|
|
511
|
+
toolErrors: 0,
|
|
512
|
+
messageUpdates: 0,
|
|
513
|
+
stopReason: '',
|
|
514
|
+
loopDetected: false,
|
|
515
|
+
loopSignature: '',
|
|
516
|
+
terminalReason: 'adapter_shutdown',
|
|
517
|
+
}))
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
|
|
463
521
|
if (heartbeatTimedOut) {
|
|
464
522
|
const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
|
|
465
523
|
const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
|
|
@@ -571,9 +629,13 @@ async function run() {
|
|
|
571
629
|
terminalReason,
|
|
572
630
|
}))
|
|
573
631
|
} finally {
|
|
632
|
+
stopWatchingParent()
|
|
574
633
|
if (heartbeatInterval) {
|
|
575
634
|
clearInterval(heartbeatInterval)
|
|
576
635
|
}
|
|
636
|
+
if (shutdownEscalationTimer) {
|
|
637
|
+
clearTimeout(shutdownEscalationTimer)
|
|
638
|
+
}
|
|
577
639
|
stopReading()
|
|
578
640
|
for (const current of pending.values()) {
|
|
579
641
|
current.reject(new Error('RPC adapter shutting down'))
|
package/src/pi-supervisor.mjs
CHANGED
|
@@ -30,11 +30,13 @@ import {
|
|
|
30
30
|
releaseRunLock,
|
|
31
31
|
runVerification,
|
|
32
32
|
runShellCommand,
|
|
33
|
+
signalOwnedChildProcesses,
|
|
33
34
|
stageFiles,
|
|
34
35
|
unstageFiles,
|
|
35
36
|
updateRunLock,
|
|
36
37
|
runVisualCapture,
|
|
37
38
|
timestamp,
|
|
39
|
+
watchParentProcess,
|
|
38
40
|
writeChangedFiles,
|
|
39
41
|
writeSessionId,
|
|
40
42
|
writeState,
|
|
@@ -49,13 +51,30 @@ import {
|
|
|
49
51
|
import { runStartupPreflight } from './pi-preflight.mjs'
|
|
50
52
|
|
|
51
53
|
let stopRequested = false
|
|
54
|
+
let shutdownEscalationTimer = null
|
|
52
55
|
|
|
53
|
-
|
|
56
|
+
function requestStop() {
|
|
54
57
|
stopRequested = true
|
|
55
|
-
|
|
58
|
+
signalOwnedChildProcesses('SIGTERM')
|
|
59
|
+
|
|
60
|
+
if (!shutdownEscalationTimer) {
|
|
61
|
+
shutdownEscalationTimer = setTimeout(() => {
|
|
62
|
+
signalOwnedChildProcesses('SIGKILL')
|
|
63
|
+
}, 1000)
|
|
64
|
+
if (typeof shutdownEscalationTimer.unref === 'function') {
|
|
65
|
+
shutdownEscalationTimer.unref()
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
56
69
|
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
71
|
+
process.on(signal, () => {
|
|
72
|
+
requestStop()
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stopWatchingParent = watchParentProcess(() => {
|
|
77
|
+
requestStop()
|
|
59
78
|
})
|
|
60
79
|
|
|
61
80
|
function sleep(seconds) {
|
|
@@ -1728,6 +1747,10 @@ async function main() {
|
|
|
1728
1747
|
await appendLog(config.logFile, 'Stop requested by signal')
|
|
1729
1748
|
}
|
|
1730
1749
|
} finally {
|
|
1750
|
+
stopWatchingParent()
|
|
1751
|
+
if (shutdownEscalationTimer) {
|
|
1752
|
+
clearTimeout(shutdownEscalationTimer)
|
|
1753
|
+
}
|
|
1731
1754
|
await updateRunOwnership(config, {
|
|
1732
1755
|
status: stopRequested ? 'stopped' : 'finished',
|
|
1733
1756
|
heartbeatAt: timestamp(),
|