@agfpd/iapeer 0.4.63 → 0.4.64

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.
@@ -106,9 +106,9 @@ GET /fleet/v1/events?replay=50
106
106
  - A comment `: connected` marks the transition from replay to live; `: hb` comments every ~15 s keep the connection alive.
107
107
  - Latency: live events surface within ~0.5 s (log-tail poll).
108
108
 
109
- Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`, with `dead_status`/`dead_signal`).
109
+ Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `stopped` / `started` (the stop/start verbs — an operator changed a peer's ✕/○ state; `action` is `stopped`/`started` for warm runtimes, `bootout`/`bootstrap` for always-on, with `reason` on failure), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`: `cause=child-exit` with `dead_status`/`dead_signal` when the session's process died on its own, `cause=shutdown-sigterm`/`shutdown-sigint` when the supervisor was torn down — reap/stop/new), `supervisor-uncaught` (a survivable supervisor fault left as forensics; the session keeps serving).
110
110
 
111
- What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.
111
+ What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; an operator **stop/start** is `ev=stopped` / `ev=started`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.
112
112
 
113
113
  ## Commands
114
114
 
@@ -106,9 +106,9 @@ GET /fleet/v1/events?replay=50
106
106
  - Комментарий `: connected` отмечает переход от replay к live; комментарии `: hb` каждые ~15 с держат соединение.
107
107
  - Латентность: живые события всплывают в пределах ~0.5 с (поллинг хвоста лога).
108
108
 
109
- Текущий словарь `ev` (растёт со временем — см. обязанность 1): `wake`, `supervise` (с `action`: `reaped-idle` = парковка, `reaped-ephemeral`, классификация `dead`→fresh/resume, `eager-orphan-fresh`, `skipped-error`…), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (из `exits.log`, с `dead_status`/`dead_signal`).
109
+ Текущий словарь `ev` (растёт со временем — см. обязанность 1): `wake`, `supervise` (с `action`: `reaped-idle` = парковка, `reaped-ephemeral`, классификация `dead`→fresh/resume, `eager-orphan-fresh`, `skipped-error`…), `stopped` / `started` (verb'ы stop/start — оператор изменил ✕/○-состояние пира; `action` — `stopped`/`started` для warm-рантаймов, `bootout`/`bootstrap` для always-on, с `reason` при неудаче), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (из `exits.log`: `cause=child-exit` с `dead_status`/`dead_signal`, когда процесс сессии умер сам; `cause=shutdown-sigterm`/`shutdown-sigint`, когда супервизор был погашен — reap/stop/new), `supervisor-uncaught` (пережитый сбой супервизора, оставленный как форензика; сессия продолжает работать).
110
110
 
111
- Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.
111
+ Как выглядят главные состояния на потоке: **пробуждение** пира — `ev=wake`; **парковка в сон** — `ev=supervise action=reaped-idle`; операторский **stop/start** — `ev=stopped` / `ev=started`; **смерть** — `ev=session-exit` (немедленно, от супервизора), затем классификация демона на его следующем проходе.
112
112
 
113
113
  ## Команды
114
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.63",
3
+ "version": "0.4.64",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -253,12 +253,9 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
253
253
  killSession(sock, identity, env)
254
254
  const stderrText = (r.stderr ?? '').trim()
255
255
  const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
256
- out.push({
257
- personality,
258
- runtime: rt,
259
- action: 'bootout',
260
- reason: benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`,
261
- })
256
+ const reason = benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`
257
+ out.push({ personality, runtime: rt, action: 'bootout', reason })
258
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'stopped', identity, personality, runtime: rt, action: 'bootout', reason }, { env })
262
259
  } else {
263
260
  // A deliberate stop is a CLEAN PARK, not a death (stop→start must survive ≥
264
261
  // idle-reap): park-mark BEFORE the kill so the post-`start`
@@ -270,6 +267,12 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
270
267
  killSession(sock, identity, env)
271
268
  removeSessionState(cfg, identity)
272
269
  out.push({ personality, runtime: rt, action: 'stopped' })
270
+ // Durable trace (fleet-API follow-up): the verb was SILENT in lifecycle.log — a
271
+ // CLI stop changed the fleet state (● → ✕) with no event, so an SSE-fed client
272
+ // (tray class) showed a stale status indefinitely (no event ⇒ no snapshot
273
+ // re-read; only the initiator knew). One line per state-changing outcome; the
274
+ // refused-foreign-launchd branch changes nothing and stays silent.
275
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'stopped', identity, personality, runtime: rt, action: 'stopped' }, { env })
273
276
  }
274
277
  }
275
278
  return out
@@ -301,17 +304,17 @@ export function startPeer(personality: string, runtime: string | undefined, opts
301
304
  // sentinel fleet-guard + sandbox guard the raw spawn never had.)
302
305
  const r = launchctlBootstrap(personality, plist, env)
303
306
  const ok = r.state === 'loaded' || r.state === 'already-loaded' || r.state === 'skipped-sandbox'
304
- out.push({
305
- personality,
306
- runtime: rt,
307
- action: 'bootstrap',
308
- reason: ok
309
- ? undefined
310
- : `launchctl bootstrap FAILED${r.detail ? `: ${r.detail}` : ''} — peer not started; manual rescue: launchctl bootstrap gui/$(id -u) ${plist}`,
311
- })
307
+ const reason = ok
308
+ ? undefined
309
+ : `launchctl bootstrap FAILED${r.detail ? `: ${r.detail}` : ''} — peer not started; manual rescue: launchctl bootstrap gui/$(id -u) ${plist}`
310
+ out.push({ personality, runtime: rt, action: 'bootstrap', reason })
311
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'started', identity, personality, runtime: rt, action: 'bootstrap', reason }, { env })
312
312
  } else {
313
313
  clearStopped(cfg, identity)
314
314
  out.push({ personality, runtime: rt, action: 'started' })
315
+ // Durable trace — mirror of the `stopped` event in stopPeer (✕ → ○ is a state
316
+ // change an SSE-fed client must hear about).
317
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'started', identity, personality, runtime: rt, action: 'started' }, { env })
315
318
  }
316
319
  }
317
320
  return out
@@ -25,6 +25,7 @@ import {
25
25
  splitDanglingEscape,
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
+ import { formatEventLine } from '../storage/rotatelog.ts'
28
29
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
30
  import { newStuckGate, nextBootAction, nextNagAction, paneIsStuck, type BootPredicates, type NagPredicate } from './boot.ts'
30
31
  import { modelToPlainText } from './render.ts'
@@ -289,6 +290,30 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
289
290
  return ok
290
291
  }
291
292
 
293
+ // ── death-trace (fleet-API follow-up, 05.07) ──────────────────────────────────
294
+ // exits.log is the durable «why did this session end» record. Before this, ONLY a
295
+ // child that died on its own (exit callback below) wrote a line; a SIGTERM'd
296
+ // supervisor (reap / stop / new / self-fresh — EVERY daemon-initiated teardown)
297
+ // killed the child and process.exit'ed before the callback could run, and a
298
+ // supervisor crash left nothing at all — exits.log stayed silent for weeks while
299
+ // sessions died (live gap: claude-boris server-dead 04.07 23:59 with ZERO forensics;
300
+ // the supervise classification even points the investigator at exits.log). Now every
301
+ // in-process exit path writes exactly one line: `exitLogged` dedups the child-exit
302
+ // callback vs the shutdown path. logfmt via the shared writer (quoted values parse
303
+ // intact in the fleet SSE stream). Out-of-process deaths (SIGKILL / OOM / Bun panic)
304
+ // remain unrecordable from here by nature — for those the supervise-tick reaped-gone
305
+ // classification in lifecycle.log stays the only trace.
306
+ let exitLogged = false
307
+ const appendExitEvent = (fields: Record<string, string | number | undefined>): void => {
308
+ if (!opts.exitLogPath) return
309
+ try {
310
+ mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 })
311
+ appendFileSync(opts.exitLogPath, `${formatEventLine(Date.now(), fields)}\n`)
312
+ } catch {
313
+ /* best-effort — the trace must never block serving/teardown */
314
+ }
315
+ }
316
+
292
317
  const child = Bun.spawn(CMD, {
293
318
  cwd: opts.cwd ?? runDir,
294
319
  env,
@@ -337,20 +362,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
337
362
  // supervise-tick death classification + crash-loop accounting have the cause. host=supervisor
338
363
  // distinguishes the source. The Bun-native pty exit callback passes (exitCode, signalCode) like
339
364
  // onExit; fall back to the subprocess props. Best-effort — never blocks shutdown.
340
- if (opts.exitLogPath) {
341
- try {
342
- mkdirSync(dirname(opts.exitLogPath), { recursive: true, mode: 0o700 }) // ensure the exits.log dir exists
343
- const c = child as { exitCode?: number | null; signalCode?: number | null }
344
- const status = exitCode ?? c.exitCode ?? ''
345
- const signal = signalCode ?? c.signalCode ?? ''
346
- appendFileSync(
347
- opts.exitLogPath,
348
- `ts=${new Date().toISOString()} ev=session-exit identity=${session} dead_status=${status} dead_signal=${signal} host=supervisor\n`,
349
- )
350
- } catch {
351
- /* best-effort */
352
- }
353
- }
365
+ const c = child as { exitCode?: number | null; signalCode?: number | null }
366
+ const status = exitCode ?? c.exitCode
367
+ const signal = signalCode ?? c.signalCode
368
+ exitLogged = true // this IS the death record — shutdown below must not write a second one
369
+ appendExitEvent({
370
+ ev: 'session-exit',
371
+ identity: session,
372
+ dead_status: status ?? '',
373
+ dead_signal: signal ?? '',
374
+ cause: 'child-exit',
375
+ host: 'supervisor',
376
+ })
354
377
  shutdown()
355
378
  },
356
379
  },
@@ -554,9 +577,17 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
554
577
  writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
555
578
 
556
579
  let down = false
557
- function shutdown(): void {
580
+ function shutdown(cause = 'shutdown'): void {
558
581
  if (down) return
559
582
  down = true
583
+ // Death-trace: a signalled shutdown kills the child below and process.exit()s
584
+ // immediately — the child exit callback never runs, so THIS is the only chance
585
+ // to record the session's end. `exitLogged` is already set when the child died
586
+ // on its own (the callback wrote the authoritative dead_status/dead_signal line).
587
+ if (!exitLogged) {
588
+ exitLogged = true
589
+ appendExitEvent({ ev: 'session-exit', identity: session, cause, host: 'supervisor' })
590
+ }
560
591
  if (paneLogFd >= 0)
561
592
  try {
562
593
  closeSync(paneLogFd)
@@ -582,18 +613,31 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
582
613
  }
583
614
  process.exit(0)
584
615
  }
585
- process.on('SIGTERM', shutdown)
586
- process.on('SIGINT', shutdown)
616
+ process.on('SIGTERM', () => shutdown('shutdown-sigterm'))
617
+ process.on('SIGINT', () => shutdown('shutdown-sigint'))
587
618
  process.on('SIGHUP', () => {
588
619
  /* daemon survives client window close */
589
620
  })
590
- process.on('uncaughtException', e => {
621
+ // A survivable fault must still leave a trace: the console.error goes to the
622
+ // detached daemon's session log, which sat at 0 bytes for weeks on the live fleet —
623
+ // effectively invisible. Record it in exits.log too (ev=supervisor-uncaught; the
624
+ // session keeps serving — killing it here would regress the detach-crash robustness),
625
+ // so a later wedge/death postmortem can see the fault that preceded it.
626
+ const traceFault = (kind: string, e: unknown): void => {
591
627
  try {
592
- console.error('[supervisor-daemon] uncaught', (e && (e as Error).stack) || e)
628
+ console.error(`[supervisor-daemon] ${kind}`, (e && (e as Error).stack) || e)
593
629
  } catch {
594
630
  /* */
595
631
  }
596
- })
632
+ appendExitEvent({
633
+ ev: 'supervisor-uncaught',
634
+ identity: session,
635
+ kind,
636
+ reason: String((e as Error)?.stack ?? e).replace(/\s+/g, ' ').slice(0, 300),
637
+ })
638
+ }
639
+ process.on('uncaughtException', e => traceFault('exception', e))
640
+ process.on('unhandledRejection', e => traceFault('rejection', e))
597
641
 
598
642
  // ── boot-dialog driver (serving slice a) ────────────────────────────────────────
599
643
  // For a real runtime, answer the startup dialogs (codex trust/update/hooks, claude trust/resume)