@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.
- package/docs/15-fleet-api.md +2 -2
- package/docs/ru/15-fleet-api.md +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +17 -14
- package/src/supervisor/daemon.ts +64 -20
package/docs/15-fleet-api.md
CHANGED
|
@@ -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
|
|
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
|
|
package/docs/ru/15-fleet-api.md
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
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(
|
|
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)
|