@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 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
 
@@ -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.1",
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.kill(process.pid, signal)
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
- const normalizedPid = normalizePid(pid)
215
- if (normalizedPid <= 0) {
216
- return false
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
- try {
220
- if (process.platform !== 'win32') {
221
- process.kill(-normalizedPid, signal)
222
- } else {
223
- process.kill(normalizedPid, signal)
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
- return true
226
- } catch {
227
- return false
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
  }
@@ -10,7 +10,12 @@ import {
10
10
  getHeartbeatDecision,
11
11
  resolveHeartbeatConfig,
12
12
  } from './pi-heartbeat.mjs'
13
- import { signalProcessTree } from './pi-repo.mjs'
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'))
@@ -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
- process.on('SIGINT', () => {
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
- process.on('SIGTERM', () => {
58
- stopRequested = true
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(),