@agfpd/iapeer 0.4.133 → 0.4.135
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/04-lifecycle-and-daemon.md +20 -1
- package/docs/12-cli-reference.md +1 -1
- package/docs/14-configuration-and-logs.md +1 -0
- package/docs/15-fleet-api.md +1 -1
- package/docs/20-opencode.md +4 -0
- package/package.json +3 -1
- package/scripts/proof-fleet-isolation.ts +71 -0
- package/scripts/proof-inflight-turns.ts +89 -0
- package/src/approval/install.ts +4 -0
- package/src/cli/index.ts +26 -12
- package/src/cli/listTui.ts +1 -1
- package/src/daemon/fleet.ts +2 -1
- package/src/daemon/main.ts +23 -2
- package/src/launch/adapters/codex.ts +24 -2
- package/src/launch/index.ts +21 -4
- package/src/launch/launchd.ts +4 -0
- package/src/launch/launchdRun.ts +1 -1
- package/src/launch/ptyHost.ts +96 -8
- package/src/lifecycle/index.ts +76 -12
- package/src/supervisor/capture.ts +43 -0
- package/src/supervisor/index.ts +29 -2
- package/src/transport/index.ts +24 -13
- package/src/tray/render.ts +5 -4
- package/src/tui/dashboard/app.tsx +2 -2
- package/src/uninstall/index.ts +8 -1
- package/src/update/index.ts +5 -0
package/src/launch/ptyHost.ts
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
// - the deliver client is a pure-protocol leaf (no @xterm);
|
|
11
11
|
// - sessionAlive comes from client.ts (no @xterm);
|
|
12
12
|
// - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
13
|
+
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
14
15
|
import { deliverToHost, sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
16
|
import { attachedPath, defaultRunDir, pidPath, readGeometry } from '../supervisor/paths.ts'
|
|
16
17
|
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
|
-
import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
|
|
18
|
+
import { killSession as killSupervisorSession, listSessions as listSupervisorSessions, type TerminationContext } from '../supervisor/index.ts'
|
|
18
19
|
import { parseSessionName } from '../core/socket.ts'
|
|
19
20
|
import type { TmuxRuntime } from '../core/constants.ts'
|
|
20
21
|
import { paneLogViewport } from './readyGateModel.ts'
|
|
@@ -45,10 +46,83 @@ export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = proc
|
|
|
45
46
|
return hostDaemonAlive(hostRunDir(env), identity)
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// ─── Runtime READINESS (incident 18.10) ──────────────────────────────────────
|
|
50
|
+
// «Alive» (the supervisor daemon's pid) is not «the runtime is up». A codex process hung before main
|
|
51
|
+
// (dyld, after a cask update) lived under a healthy supervisor for 35 min: `list` showed ●, a send
|
|
52
|
+
// went «live» and was confirmed ok (the cooked tty echoed the paste into the pane-log), and the ledger
|
|
53
|
+
// held the message as in-progress. The invariant: a session that never reached its runtime's ready
|
|
54
|
+
// surface is NOT live for delivery/list. Operator controls still address the hung process.
|
|
55
|
+
// The launch writes this marker (the hosting token) the moment its ready-gate passes; a marker from
|
|
56
|
+
// another hosting (a replaced session) does not count.
|
|
57
|
+
|
|
58
|
+
function readyPath(identity: string, env: NodeJS.ProcessEnv): string {
|
|
59
|
+
return join(hostRunDir(env), `${identity}.ready`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Record that THIS hosting of the identity reached its runtime's ready surface. */
|
|
63
|
+
export function markHostReady(identity: string, env: NodeJS.ProcessEnv = process.env, token = hostSessionToken(identity, env)): void {
|
|
64
|
+
if (!token || token !== hostSessionToken(identity, env)) throw new Error(`hosting changed before ready: ${identity}`)
|
|
65
|
+
writeFileSync(readyPath(identity, env), `${token}\n`, { mode: 0o600 })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function clearHostReady(identity: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
69
|
+
rmSync(readyPath(identity, env), { force: true })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** When this hosting started (its supervisor pid-file mtime), or null. */
|
|
73
|
+
export function hostStartedMs(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): number | null {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(pidPath(runDir, identity)).mtimeMs
|
|
76
|
+
} catch {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Alive AND this very hosting reached ready. */
|
|
82
|
+
export function hostSessionReady(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): boolean {
|
|
83
|
+
if (!hostDaemonAlive(runDir, identity)) return false
|
|
84
|
+
const token = hostSessionToken(identity, env, runDir)
|
|
85
|
+
try {
|
|
86
|
+
return !!token && readFileSync(join(runDir, `${identity}.ready`), 'utf8').trim() === token
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One-time compatibility for sessions that were already running when the readiness marker was
|
|
94
|
+
* introduced. Prove readiness in each current host, without trusting historical pane-log frames.
|
|
95
|
+
*/
|
|
96
|
+
export async function backfillHostReady(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
|
|
97
|
+
const out: string[] = []
|
|
98
|
+
for (const h of listHostedPeers(env)) {
|
|
99
|
+
const id = `${h.runtime}-${h.personality}`
|
|
100
|
+
if (existsSync(readyPath(id, env))) continue
|
|
101
|
+
const token = hostSessionToken(id, env)
|
|
102
|
+
// Migration must prove readiness too: blindly stamping legacy pids re-labels a dyld hang as live.
|
|
103
|
+
const { getAdapter } = await import('./index.ts')
|
|
104
|
+
const adapter = getAdapter(h.runtime)
|
|
105
|
+
if (h.runtime === 'opencode') {
|
|
106
|
+
const { opencodeControl } = await import('../opencode/control.ts')
|
|
107
|
+
const { opencodePaths } = await import('../opencode/config.ts')
|
|
108
|
+
const ping = await opencodeControl(join(opencodePaths(h.personality, { env }).dir, 'control.sock'), { op: 'ping' }, 1000)
|
|
109
|
+
if (!ping.ok) continue
|
|
110
|
+
} else if (adapter.kind !== 'router') {
|
|
111
|
+
const { captureHostViewport } = await import('../supervisor/capture.ts')
|
|
112
|
+
const view = await captureHostViewport(hostRunDir(env), id)
|
|
113
|
+
if (!view || !adapter.isInputReady(view, 'gated')) continue
|
|
114
|
+
}
|
|
115
|
+
markHostReady(id, env, token)
|
|
116
|
+
out.push(id)
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
48
121
|
/** Reap a hosted session: SIGTERM the supervisor daemon (hard-kills its PTY-owned tree + cleans up).
|
|
49
122
|
* env sandboxes the run-dir: a test with an injected root must NEVER SIGTERM a real fleet peer. */
|
|
50
|
-
export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
51
|
-
killSupervisorSession(
|
|
123
|
+
export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env, context: TerminationContext = {}, runDir = hostRunDir(env)): void {
|
|
124
|
+
killSupervisorSession(runDir, identity, { ...context, sandbox: context.sandbox || env.IAPEER_TEST_SANDBOX === '1',
|
|
125
|
+
caller: context.caller ?? env.PEER_IDENTITY ?? (env.PEER_PERSONALITY ? `${env.PEER_RUNTIME ?? 'unknown'}-${env.PEER_PERSONALITY}` : undefined) })
|
|
52
126
|
}
|
|
53
127
|
|
|
54
128
|
/**
|
|
@@ -78,9 +152,9 @@ export function hostGeometry(identity: string, env: NodeJS.ProcessEnv = process.
|
|
|
78
152
|
* session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
|
|
79
153
|
* `session_id:pane_id` token. null when no daemon pid is readable.
|
|
80
154
|
*/
|
|
81
|
-
export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
155
|
+
export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): string | null {
|
|
82
156
|
try {
|
|
83
|
-
const pid = readFileSync(pidPath(
|
|
157
|
+
const pid = readFileSync(pidPath(runDir, identity), 'utf8').trim()
|
|
84
158
|
return pid ? `host:${pid}` : null
|
|
85
159
|
} catch {
|
|
86
160
|
return null
|
|
@@ -93,9 +167,9 @@ export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = proc
|
|
|
93
167
|
* scan (and the no-runtime delivery resolve) include hosted peers. Empty + cheap when flag-off (the
|
|
94
168
|
* run-dir is empty).
|
|
95
169
|
*/
|
|
96
|
-
export function listHostedPeers(env: NodeJS.ProcessEnv = process.env): Array<{ runtime: TmuxRuntime; personality: string }> {
|
|
170
|
+
export function listHostedPeers(env: NodeJS.ProcessEnv = process.env, runDir = hostRunDir(env)): Array<{ runtime: TmuxRuntime; personality: string }> {
|
|
97
171
|
const out: Array<{ runtime: TmuxRuntime; personality: string }> = []
|
|
98
|
-
for (const s of listSupervisorSessions(
|
|
172
|
+
for (const s of listSupervisorSessions(runDir)) {
|
|
99
173
|
if (!s.alive) continue
|
|
100
174
|
const p = parseSessionName(s.session)
|
|
101
175
|
if (p) out.push({ runtime: p.runtime, personality: p.personality })
|
|
@@ -206,3 +280,17 @@ export function deliverHosted(identity: string, message: string, env: NodeJS.Pro
|
|
|
206
280
|
export function resubmitHosted(identity: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
207
281
|
return sendControlToHost(hostRunDir(env), identity, [Buffer.from('\r', 'utf8')])
|
|
208
282
|
}
|
|
283
|
+
|
|
284
|
+
/** Run backfillHostReady exactly once per host (sentinel in the run-dir). Every later hosting is a
|
|
285
|
+
* launch that writes its own marker, so a repeat backfill could only mask a never-ready host. */
|
|
286
|
+
export async function backfillHostReadyOnce(env: NodeJS.ProcessEnv = process.env): Promise<string[] | null> {
|
|
287
|
+
const sentinel = join(hostRunDir(env), 'ready-backfill.v1')
|
|
288
|
+
if (existsSync(sentinel)) return null
|
|
289
|
+
const stamped = await backfillHostReady(env)
|
|
290
|
+
try {
|
|
291
|
+
writeFileSync(sentinel, `${new Date().toISOString()} ${stamped.join(',')}\n`, { mode: 0o600 })
|
|
292
|
+
} catch {
|
|
293
|
+
/* best-effort */
|
|
294
|
+
}
|
|
295
|
+
return stamped
|
|
296
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -51,7 +51,8 @@ import {
|
|
|
51
51
|
type LaunchSpec,
|
|
52
52
|
} from '../launch/index.ts'
|
|
53
53
|
import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
|
|
54
|
-
import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
|
|
54
|
+
import { hostSessionAlive, hostSessionReady, hostStartedMs, killPtyHost, listHostedPeers } from '../launch/ptyHost.ts'
|
|
55
|
+
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
55
56
|
import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
56
57
|
|
|
57
58
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -59,6 +60,8 @@ import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
|
59
60
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
60
61
|
|
|
61
62
|
export interface LifecycleConfig {
|
|
63
|
+
/** Process-control namespace, bound when cfg is loaded; never re-resolve it from deps.env. */
|
|
64
|
+
supervisorRunDir?: string
|
|
62
65
|
claudeBin: string
|
|
63
66
|
codexBin: string
|
|
64
67
|
/** OpenCode native binary (docs/20). Optional so hand-built test configs stay valid. */
|
|
@@ -110,6 +113,7 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
|
|
|
110
113
|
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
111
114
|
}
|
|
112
115
|
return {
|
|
116
|
+
supervisorRunDir: join(root, STATE_DIR, 'supervisor'),
|
|
113
117
|
claudeBin: env.IAPEER_CLAUDE_BIN ?? join(home, '.local', 'bin', 'claude'),
|
|
114
118
|
codexBin: env.IAPEER_CODEX_BIN ?? 'codex',
|
|
115
119
|
// The official installer's location is not on launchd's PATH; prefer it over a bare name.
|
|
@@ -1096,6 +1100,17 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1096
1100
|
}
|
|
1097
1101
|
// Idempotent fast path inside the lock: a live session wins (a concurrent
|
|
1098
1102
|
// wake already brought it up) — no second spawn.
|
|
1103
|
+
// ALIVE but NOT READY while we hold the wake lock = no bring-up is in flight for it, yet it never
|
|
1104
|
+
// reached its ready surface (a hung-before-main process, an abandoned bring-up). Not a session —
|
|
1105
|
+
// replace it rather than report READY into a process that will never take input (18.10).
|
|
1106
|
+
if (sessionAlive(sock, identity, env) && !hostSessionReady(identity, env)) {
|
|
1107
|
+
killPtyHost(identity, env, { reason: 'wake-replace-never-ready' })
|
|
1108
|
+
logWake({ identity, runtime, mode: 'replace', cause: 'alive-never-ready' })
|
|
1109
|
+
// SIGTERM is asynchronous — wait (bounded) for the host to be gone, so the fast path below does
|
|
1110
|
+
// not read the dying process as the live winner.
|
|
1111
|
+
for (let i = 0; i < 25 && sessionAlive(sock, identity, env); i++) await new Promise(r => setTimeout(r, 200))
|
|
1112
|
+
if (sessionAlive(sock, identity, env)) return { status: 'FAILED', woke: false, runtime, reason: 'never-ready host did not terminate; retry after recovery' }
|
|
1113
|
+
}
|
|
1099
1114
|
if (sessionAlive(sock, identity, env)) {
|
|
1100
1115
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1101
1116
|
// taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
|
|
@@ -1302,12 +1317,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1302
1317
|
// Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
|
|
1303
1318
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1304
1319
|
|
|
1305
|
-
export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
1320
|
+
export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env, reason = 'lifecycle-kill'): void {
|
|
1306
1321
|
// pty-only: SIGTERM the supervisor daemon — its shutdown kills the PTY-owned tree and removes the
|
|
1307
1322
|
// sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
|
|
1308
1323
|
// call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
|
|
1309
1324
|
// injected root must NEVER SIGTERM a real fleet peer's supervisor.
|
|
1310
|
-
killPtyHost(identity, env)
|
|
1325
|
+
killPtyHost(identity, env, { reason })
|
|
1311
1326
|
}
|
|
1312
1327
|
|
|
1313
1328
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1348,6 +1363,9 @@ export interface SuperviseDeps {
|
|
|
1348
1363
|
/** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
|
|
1349
1364
|
* verdict so the idle path is exercised hermetically — no live pty session needed. */
|
|
1350
1365
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1366
|
+
/** Readiness seam (default: launch's ready marker for THIS hosting; with only sessionAlive injected,
|
|
1367
|
+
* ready ≡ alive). */
|
|
1368
|
+
sessionReady?: (identity: string) => boolean
|
|
1351
1369
|
/** Reap seam (default: the real `killSession` = SIGTERM the supervisor daemon). Tests inject a no-op
|
|
1352
1370
|
* so exercising the reap path NEVER SIGTERMs a real process — the destructive-kill isolation guard. */
|
|
1353
1371
|
killSession?: (sock: string, identity: string) => void
|
|
@@ -1361,7 +1379,9 @@ interface SuperviseCtx {
|
|
|
1361
1379
|
lastTurnMt: (runtime: string, cwd: string, personality?: string) => number | null
|
|
1362
1380
|
childActivityMt: (runtime: string, cwd: string) => number | null
|
|
1363
1381
|
isAlive: (sock: string, identity: string) => boolean
|
|
1364
|
-
|
|
1382
|
+
/** Has THIS hosting reached its runtime's ready surface (launch's ready marker)? */
|
|
1383
|
+
isReady: (identity: string) => boolean
|
|
1384
|
+
kill: (sock: string, identity: string, reason: string) => void
|
|
1365
1385
|
verbose: boolean
|
|
1366
1386
|
trace: (fields: Record<string, string | number | undefined>) => void
|
|
1367
1387
|
}
|
|
@@ -1374,7 +1394,7 @@ interface SuperviseCtx {
|
|
|
1374
1394
|
* returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
|
|
1375
1395
|
*/
|
|
1376
1396
|
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1377
|
-
const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace } = ctx
|
|
1397
|
+
const { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, isReady, kill, verbose, trace } = ctx
|
|
1378
1398
|
// H4 — FIRST, before any reap. A launchd-managed peer is read-only: never wake,
|
|
1379
1399
|
// never kill, never sweep its PROCESS. Its stale `.session` however is the
|
|
1380
1400
|
// DAEMON'S own supervisory record (written back when the peer was daemon-owned),
|
|
@@ -1448,13 +1468,27 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1448
1468
|
// cleared above; no legitimate alive+stopped state exists. Park like the stop verb (.idle-reaped is
|
|
1449
1469
|
// idempotent with it) — .stopped keeps the peer down until `start`, .idle-reaped makes it resume-clean.
|
|
1450
1470
|
if (isStopped(cfg, s.identity)) {
|
|
1451
|
-
kill(sock, s.identity)
|
|
1471
|
+
kill(sock, s.identity, 'supervise-stop-enforced')
|
|
1452
1472
|
setIdleReaped(cfg, s.identity)
|
|
1453
1473
|
clearEphemeralArmed(cfg, s.identity)
|
|
1454
1474
|
removeSessionState(cfg, s.identity)
|
|
1455
1475
|
trace({ identity: s.identity, action: 'reaped-idle', reason: 'alive-but-stopped', outcome: 'operator-halt-enforced' })
|
|
1456
1476
|
return { identity: s.identity, action: 'reaped-idle', reason: 'live session carried .stopped — operator halt enforced (stop-vs-wake race)' }
|
|
1457
1477
|
}
|
|
1478
|
+
// NEVER-READY (incident 18.10): a process that never reached its runtime's ready surface well past
|
|
1479
|
+
// the boot + ready-gate deadlines is not a session — a codex hung before main lived 35 min under a
|
|
1480
|
+
// healthy supervisor. Launch now tears such a host down itself; this is the backstop for a bring-up
|
|
1481
|
+
// the launcher could not finish (daemon restarted mid-boot, a launcher crash). Reaped as a FAULT
|
|
1482
|
+
// (recordDeath → the crash-loop guard bounds a boot that keeps hanging); its ledger entries then
|
|
1483
|
+
// orphan and are redelivered to the next session.
|
|
1484
|
+
const boundMs = (cfg.bootDeadlineSecs + cfg.readyGateSecs + 60) * 1000
|
|
1485
|
+
if (!isReady(s.identity) && nowMs - s.wokeAt > boundMs) {
|
|
1486
|
+
kill(sock, s.identity, 'supervise-never-ready')
|
|
1487
|
+
recordDeath(cfg, s.identity, nowMs)
|
|
1488
|
+
removeSessionState(cfg, s.identity)
|
|
1489
|
+
trace({ identity: s.identity, action: 'reaped-gone', reason: 'never-ready', age: `${Math.floor((nowMs - s.wokeAt) / 1000)}s`, outcome: 'fresh-next-msg' })
|
|
1490
|
+
return { identity: s.identity, action: 'reaped-gone', reason: 'alive but never reached its ready surface' }
|
|
1491
|
+
}
|
|
1458
1492
|
// Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
|
|
1459
1493
|
// — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
|
|
1460
1494
|
// • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
|
|
@@ -1495,7 +1529,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1495
1529
|
// Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
|
|
1496
1530
|
// and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
|
|
1497
1531
|
if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
|
|
1498
|
-
kill(sock, s.identity)
|
|
1532
|
+
kill(sock, s.identity, 'supervise-ephemeral-done')
|
|
1499
1533
|
clearEphemeralArmed(cfg, s.identity)
|
|
1500
1534
|
removeSessionState(cfg, s.identity)
|
|
1501
1535
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
@@ -1521,7 +1555,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1521
1555
|
// tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
|
|
1522
1556
|
// reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
|
|
1523
1557
|
if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1524
|
-
kill(sock, s.identity)
|
|
1558
|
+
kill(sock, s.identity, 'supervise-ephemeral-unarmed')
|
|
1525
1559
|
clearEphemeralArmed(cfg, s.identity)
|
|
1526
1560
|
removeSessionState(cfg, s.identity)
|
|
1527
1561
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
@@ -1550,7 +1584,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1550
1584
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1551
1585
|
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
1552
1586
|
// branch above) never writes it → that wakes FRESH (branch 3a).
|
|
1553
|
-
kill(sock, s.identity)
|
|
1587
|
+
kill(sock, s.identity, 'supervise-idle')
|
|
1554
1588
|
setIdleReaped(cfg, s.identity)
|
|
1555
1589
|
removeSessionState(cfg, s.identity)
|
|
1556
1590
|
trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
|
|
@@ -1567,6 +1601,8 @@ const limitHoldTraced = new Set<string>()
|
|
|
1567
1601
|
|
|
1568
1602
|
export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
|
|
1569
1603
|
const env = deps.env ?? process.env
|
|
1604
|
+
// Legacy partial cfgs get a PRIVATE namespace, not the invoking host's fleet.
|
|
1605
|
+
const runDir = cfg.supervisorRunDir ?? join(cfg.stateDir, 'supervisor')
|
|
1570
1606
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1571
1607
|
const lastTurnMt =
|
|
1572
1608
|
deps.lastTurnMtime ??
|
|
@@ -1574,8 +1610,12 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1574
1610
|
const childActivityMt =
|
|
1575
1611
|
deps.childActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).childActivityMtime?.(c) ?? null)
|
|
1576
1612
|
// Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
|
|
1577
|
-
const isAlive = deps.sessionAlive ?? ((
|
|
1578
|
-
|
|
1613
|
+
const isAlive = deps.sessionAlive ?? ((_sock: string, identity: string) => hostDaemonAlive(runDir, identity))
|
|
1614
|
+
// Readiness seam. A test that injects ONLY sessionAlive gets ready ≡ alive (its fixtures predate the
|
|
1615
|
+
// marker); production reads launch's ready marker for this very hosting.
|
|
1616
|
+
const isReady =
|
|
1617
|
+
deps.sessionReady ?? (deps.sessionAlive ? (identity: string) => deps.sessionAlive!('', identity) : (identity: string) => hostSessionReady(identity, env, runDir))
|
|
1618
|
+
const kill = deps.killSession ?? ((_sock: string, identity: string, reason: string) => killPtyHost(identity, env, { reason }, runDir))
|
|
1579
1619
|
const verbose = superviseLogVerbose(env)
|
|
1580
1620
|
// Pane-log volume ROTATION (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1581
1621
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
@@ -1613,7 +1653,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1613
1653
|
})
|
|
1614
1654
|
}
|
|
1615
1655
|
const out: SuperviseOutcome[] = []
|
|
1616
|
-
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
|
|
1656
|
+
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, isReady, kill, verbose, trace }
|
|
1617
1657
|
for (const s of readSessionStates(cfg)) {
|
|
1618
1658
|
// Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
|
|
1619
1659
|
// the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
|
|
@@ -1627,6 +1667,30 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1627
1667
|
trace({ identity: s.identity, action: 'skipped-error', reason, outcome: 'peer-error-isolated' })
|
|
1628
1668
|
}
|
|
1629
1669
|
}
|
|
1670
|
+
// NEVER-READY hosts with NO `.session` (18.10): the session state is written only AFTER a successful
|
|
1671
|
+
// bring-up, so a bring-up that hung and was then abandoned (the launcher died mid-boot, a daemon
|
|
1672
|
+
// restart) leaves a live host the loop above never sees — exactly how codex-linus, hung in dyld,
|
|
1673
|
+
// stayed ● for 35 min. Walk the run-dir itself. H4: never a launchd-managed peer's host.
|
|
1674
|
+
if (!deps.sessionAlive) {
|
|
1675
|
+
const known = new Set(out.map(o => o.identity))
|
|
1676
|
+
const boundMs = (cfg.bootDeadlineSecs + cfg.readyGateSecs + 60) * 1000
|
|
1677
|
+
for (const h of listHostedPeers(env, runDir)) {
|
|
1678
|
+
const identity = `${h.runtime}-${h.personality}`
|
|
1679
|
+
if (known.has(identity)) continue
|
|
1680
|
+
try {
|
|
1681
|
+
if (isLaunchdManaged(h.personality, env)) continue
|
|
1682
|
+
if (hostSessionReady(identity, env, runDir)) continue
|
|
1683
|
+
const started = hostStartedMs(identity, env, runDir)
|
|
1684
|
+
if (started === null || nowMs - started <= boundMs) continue
|
|
1685
|
+
kill('', identity, 'supervise-never-ready-unsupervised')
|
|
1686
|
+
recordDeath(cfg, identity, nowMs)
|
|
1687
|
+
trace({ identity, action: 'reaped-gone', reason: 'never-ready-unsupervised', age: `${Math.floor((nowMs - started) / 1000)}s`, outcome: 'fresh-next-msg' })
|
|
1688
|
+
out.push({ identity, action: 'reaped-gone', reason: 'unsupervised host never reached its ready surface' })
|
|
1689
|
+
} catch {
|
|
1690
|
+
/* per-host isolation, like the loop above */
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1630
1694
|
return out
|
|
1631
1695
|
}
|
|
1632
1696
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { frame, FRAME_DATA, FRAME_RESIZE, makeFramer, sizePayload } from './protocol.ts'
|
|
2
|
+
import { readGeometry, sockPath } from './paths.ts'
|
|
3
|
+
|
|
4
|
+
/** Read the running host's OWN emulator, never the shared cross-session pane-log.
|
|
5
|
+
* Legacy hosts already implement same-size attach: no resize or input is sent to the child. */
|
|
6
|
+
export async function captureHostViewport(runDir: string, session: string): Promise<string | null> {
|
|
7
|
+
const geometry = readGeometry(runDir, session)
|
|
8
|
+
if (!geometry) return null
|
|
9
|
+
const { cols, rows } = geometry
|
|
10
|
+
const bytes = await new Promise<Buffer | null>(resolve => {
|
|
11
|
+
let conn: Awaited<ReturnType<typeof Bun.connect>> | undefined
|
|
12
|
+
const chunks: Buffer[] = []
|
|
13
|
+
let settled = false
|
|
14
|
+
let quiet: ReturnType<typeof setTimeout> | undefined
|
|
15
|
+
const finish = () => {
|
|
16
|
+
if (settled) return
|
|
17
|
+
settled = true
|
|
18
|
+
clearTimeout(deadline)
|
|
19
|
+
clearTimeout(quiet)
|
|
20
|
+
conn?.end()
|
|
21
|
+
resolve(chunks.length ? Buffer.concat(chunks) : null)
|
|
22
|
+
}
|
|
23
|
+
const deadline = setTimeout(finish, 1500)
|
|
24
|
+
const consume = makeFramer((type, payload) => {
|
|
25
|
+
if (type !== FRAME_DATA) return
|
|
26
|
+
chunks.push(Buffer.from(payload))
|
|
27
|
+
clearTimeout(quiet)
|
|
28
|
+
quiet = setTimeout(finish, 80)
|
|
29
|
+
})
|
|
30
|
+
Bun.connect({ unix: sockPath(runDir, session), socket: {
|
|
31
|
+
open(s) { s.write(frame(FRAME_RESIZE, sizePayload(cols, rows))) },
|
|
32
|
+
data(_s, data) { consume(data) }, close() { finish() }, error() { finish() }, connectError() { finish() },
|
|
33
|
+
} }).then(s => { conn = s; if (settled) s.end() }).catch(finish)
|
|
34
|
+
})
|
|
35
|
+
if (!bytes) return null
|
|
36
|
+
const { Terminal } = await import('@xterm/headless')
|
|
37
|
+
const { modelToPlainText } = await import('./render.ts')
|
|
38
|
+
const term = new Terminal({ cols, rows, allowProposedApi: true })
|
|
39
|
+
try {
|
|
40
|
+
await new Promise<void>(r => term.write(bytes, r))
|
|
41
|
+
return modelToPlainText(term, cols, rows)
|
|
42
|
+
} finally { term.dispose() }
|
|
43
|
+
}
|
package/src/supervisor/index.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
// serving is productionized (a later block) the daemon-spawn argv is repointed at the installed
|
|
6
6
|
// binary; for now it self-spawns from source (bun + this file), which is what the tests exercise.
|
|
7
7
|
import { spawn as nodeSpawn } from 'node:child_process'
|
|
8
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { formatEventLine } from '../storage/rotatelog.ts'
|
|
9
12
|
import { fileURLToPath } from 'node:url'
|
|
10
13
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
11
14
|
import { defaultRunDir, logPath, ownershipVerdict, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
|
|
@@ -217,7 +220,30 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
|
|
|
217
220
|
})
|
|
218
221
|
}
|
|
219
222
|
|
|
220
|
-
export
|
|
223
|
+
export interface TerminationContext { reason?: string; caller?: string; sandbox?: boolean; targetPid?: number; targetToken?: string }
|
|
224
|
+
|
|
225
|
+
/** Strict write-ahead audit: if attribution cannot be persisted, do not signal. No payload/env. */
|
|
226
|
+
export function auditTermination(runDir: string, targets: string[], context: TerminationContext = {}): void {
|
|
227
|
+
const canonical = (p: string) => { try { return realpathSync(p) } catch { return resolve(p) } }
|
|
228
|
+
if ((context.sandbox || process.env.IAPEER_TEST_SANDBOX === '1') && canonical(runDir) === canonical(join(homedir(), '.iapeer/state/supervisor'))) {
|
|
229
|
+
throw new Error('refusing to signal the REAL supervisor run-dir under IAPEER_TEST_SANDBOX=1')
|
|
230
|
+
}
|
|
231
|
+
mkdirSync(runDir, { recursive: true, mode: 0o700 })
|
|
232
|
+
const fd = openSync(join(runDir, 'terminations.log'), 'a', 0o600)
|
|
233
|
+
try {
|
|
234
|
+
appendFileSync(fd, formatEventLine(Date.now(), {
|
|
235
|
+
ev: targets.length > 1 ? 'termination-batch' : 'termination-request',
|
|
236
|
+
targets: targets.join(','), caller: context.caller ?? process.env.PEER_IDENTITY ??
|
|
237
|
+
(process.env.PEER_PERSONALITY ? `${process.env.PEER_RUNTIME ?? 'unknown'}-${process.env.PEER_PERSONALITY}` : `uid:${process.getuid?.() ?? 'unknown'}`),
|
|
238
|
+
caller_pid: process.pid, caller_ppid: process.ppid,
|
|
239
|
+
target_pid: context.targetPid, target_token: context.targetToken,
|
|
240
|
+
reason: context.reason ?? `supervisor-kill:${process.argv[2] ?? 'api'}`,
|
|
241
|
+
}) + '\n')
|
|
242
|
+
fsyncSync(fd)
|
|
243
|
+
} finally { closeSync(fd) }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function killSession(runDir: string, session: string, context: TerminationContext = {}): boolean {
|
|
221
247
|
const rec = readPidFile(runDir, session)
|
|
222
248
|
if (!rec) return false
|
|
223
249
|
// В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
|
|
@@ -228,6 +254,7 @@ export function killSession(runDir: string, session: string): boolean {
|
|
|
228
254
|
if (rec.token && !ownershipVerdict(pidStartToken(rec.pid), rec.token)) {
|
|
229
255
|
return false // reused pid — NOT our session; do not signal it
|
|
230
256
|
}
|
|
257
|
+
auditTermination(runDir, [session], { ...context, targetPid: rec.pid, targetToken: rec.token })
|
|
231
258
|
try {
|
|
232
259
|
process.kill(rec.pid, 'SIGTERM')
|
|
233
260
|
} catch {
|
package/src/transport/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
hostGeometry,
|
|
54
54
|
hostRunDir,
|
|
55
55
|
hostSessionAlive,
|
|
56
|
+
hostSessionReady,
|
|
56
57
|
hostSessionToken,
|
|
57
58
|
listHostedPeers,
|
|
58
59
|
resubmitHosted,
|
|
@@ -103,22 +104,29 @@ function monotonicMs(): number {
|
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
function sessionAlive(_sock: string, address: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
107
|
+
// DELIVERY liveness = the hosting is alive AND its runtime reached its ready surface (incident
|
|
108
|
+
// 18.10: a codex hung before main lived under a healthy supervisor and took a message with a false
|
|
109
|
+
// ok). A not-yet-ready session routes as a MISS → the wake path, which waits on the in-flight
|
|
110
|
+
// bring-up (wake.lock) or replaces a failed one. `address` IS the identity; `_sock` is legacy.
|
|
111
|
+
return hostSessionReady(address, env)
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
/** Is the (runtime,personality) endpoint
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return
|
|
114
|
+
/** Is the (runtime,personality) endpoint's PROCESS up right now (ready or not)? The operator-verb
|
|
115
|
+
* guard (stop/remove/rename must still reach a hung process) — NOT a delivery predicate. */
|
|
116
|
+
export function isPeerLive(runtime: string, personality: string, _sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): boolean {
|
|
117
|
+
return hostSessionAlive(buildProcessAddress(runtime, personality), env)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Is the endpoint up AND its runtime ready (accepts input)? — what `list` shows as ●. */
|
|
121
|
+
export function isPeerReady(runtime: string, personality: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
122
|
+
return hostSessionReady(buildProcessAddress(runtime, personality), env)
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
export function listOnlinePeers(_sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): OnlinePeer[] {
|
|
119
126
|
// pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
|
|
120
127
|
const out = new Map<string, OnlinePeer>()
|
|
121
128
|
for (const h of listHostedPeers(env)) {
|
|
129
|
+
// Operator discovery includes booting/hung processes so interrupt/stop can still address them.
|
|
122
130
|
out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
|
|
123
131
|
}
|
|
124
132
|
return Array.from(out.values()).sort(
|
|
@@ -132,6 +140,8 @@ export function resolveDeliveryTarget(args: {
|
|
|
132
140
|
sockDir?: string
|
|
133
141
|
/** Б7 — injected env sandboxes the liveness reads (supervisor run-dir); default process.env (prod). */
|
|
134
142
|
env?: NodeJS.ProcessEnv
|
|
143
|
+
/** Operator control can address a hung/booting process; delivery cannot. */
|
|
144
|
+
processOnly?: boolean
|
|
135
145
|
}): Result<DeliveryTarget> {
|
|
136
146
|
const env = args.env ?? process.env
|
|
137
147
|
const sockDir = args.sockDir ?? resolveSockDir(env)
|
|
@@ -143,12 +153,13 @@ export function resolveDeliveryTarget(args: {
|
|
|
143
153
|
const runtime = args.runtime
|
|
144
154
|
const socketPath = buildSocketPath(runtime, args.personality, sockDir)
|
|
145
155
|
const address = buildProcessAddress(runtime, args.personality)
|
|
146
|
-
if (!sessionAlive(socketPath, address, env)) {
|
|
156
|
+
if (!(args.processOnly ? hostSessionAlive(address, env) : sessionAlive(socketPath, address, env))) {
|
|
147
157
|
return err(`peer offline: ${args.personality} (${args.runtime})`)
|
|
148
158
|
}
|
|
149
159
|
return ok({ runtime, personality: args.personality, address, socketPath })
|
|
150
160
|
}
|
|
151
|
-
const matches = listOnlinePeers(sockDir, env).filter(peer => peer.personality === args.personality
|
|
161
|
+
const matches = listOnlinePeers(sockDir, env).filter(peer => peer.personality === args.personality &&
|
|
162
|
+
(args.processOnly || hostSessionReady(buildProcessAddress(peer.runtime, peer.personality), env)))
|
|
152
163
|
if (matches.length === 0) return err(`peer offline: ${args.personality}`)
|
|
153
164
|
if (matches.length > 1) {
|
|
154
165
|
return err(
|
|
@@ -1220,13 +1231,13 @@ export interface ControlResult {
|
|
|
1220
1231
|
* live endpoints stays ambiguous → explicit refusal. An explicit runtime is exact.
|
|
1221
1232
|
*/
|
|
1222
1233
|
export function resolveControlTarget(personality: string, runtime: string | undefined, env: NodeJS.ProcessEnv = process.env): Result<DeliveryTarget> {
|
|
1223
|
-
if (runtime) return resolveDeliveryTarget({ personality, runtime, env })
|
|
1234
|
+
if (runtime) return resolveDeliveryTarget({ personality, runtime, env, processOnly: true })
|
|
1224
1235
|
const peer = findPeer(readPeersIndex({ env }), personality)
|
|
1225
1236
|
if (peer) {
|
|
1226
|
-
const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env })
|
|
1237
|
+
const anchored = resolveDeliveryTarget({ personality, runtime: peer.runtime, env, processOnly: true })
|
|
1227
1238
|
if (anchored.ok) return anchored
|
|
1228
1239
|
}
|
|
1229
|
-
return resolveDeliveryTarget({ personality, env })
|
|
1240
|
+
return resolveDeliveryTarget({ personality, env, processOnly: true })
|
|
1230
1241
|
}
|
|
1231
1242
|
|
|
1232
1243
|
export async function routeControl(personality: string, runtime: string | undefined, command: ControlCommand): Promise<Result<ControlResult>> {
|
package/src/tray/render.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// peer row hands off to `iapeer attach`, the terminal-handoff the API deliberately
|
|
13
13
|
// keeps client-side).
|
|
14
14
|
|
|
15
|
-
const GLYPH: Record<'live' | 'asleep' | 'stopped', string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
15
|
+
const GLYPH: Record<'live' | 'booting' | 'asleep' | 'stopped', string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
|
|
16
16
|
|
|
17
17
|
const COLOR = {
|
|
18
18
|
meta: '#8b949e',
|
|
@@ -31,7 +31,7 @@ const APPROVAL_CONTENT_MAX_CHARS = 8000
|
|
|
31
31
|
|
|
32
32
|
export interface TrayRuntime {
|
|
33
33
|
runtime: string
|
|
34
|
-
status: 'live' | 'asleep' | 'stopped'
|
|
34
|
+
status: 'live' | 'booting' | 'asleep' | 'stopped'
|
|
35
35
|
attached?: boolean
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -134,13 +134,14 @@ export function fmtClock(now: number): string {
|
|
|
134
134
|
|
|
135
135
|
// ── peer status aggregation ─────────────────────────────────────────────────────
|
|
136
136
|
|
|
137
|
-
function aggregate(p: TrayPeer): 'live' | 'asleep' | 'stopped' {
|
|
137
|
+
function aggregate(p: TrayPeer): 'live' | 'booting' | 'asleep' | 'stopped' {
|
|
138
138
|
if (p.runtimes.some(r => r.status === 'live')) return 'live'
|
|
139
|
+
if (p.runtimes.some(r => r.status === 'booting')) return 'booting'
|
|
139
140
|
if (p.runtimes.some(r => r.status === 'asleep')) return 'asleep'
|
|
140
141
|
return 'stopped'
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
const RANK: Record<'live' | 'asleep' | 'stopped', number> = { live: 0,
|
|
144
|
+
const RANK: Record<'live' | 'booting' | 'asleep' | 'stopped', number> = { live: 0, booting: 1, asleep: 2, stopped: 3 }
|
|
144
145
|
|
|
145
146
|
// 🔒 launchd-managed peers (the human channel + service infra — arthur/timer/watcher) are
|
|
146
147
|
// NON-clickable status rows. They form ONE contiguous block at the very TOP, before the first
|
|
@@ -23,8 +23,8 @@ const POLL_MS = 2000
|
|
|
23
23
|
const HOST_POLL_MS = 10_000
|
|
24
24
|
const LOG_LIMIT = 12
|
|
25
25
|
|
|
26
|
-
const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
27
|
-
const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', asleep: undefined, stopped: 'red' }
|
|
26
|
+
const STATUS_GLYPH: Record<RuntimeLiveness, string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
|
|
27
|
+
const STATUS_COLOR: Record<RuntimeLiveness, string | undefined> = { live: 'green', booting: 'yellow', asleep: undefined, stopped: 'red' }
|
|
28
28
|
|
|
29
29
|
function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
|
|
30
30
|
return (
|
package/src/uninstall/index.ts
CHANGED
|
@@ -226,9 +226,12 @@ async function waitJobGone(run: SysRunner, uid: number, label: string, timeoutMs
|
|
|
226
226
|
* wait for its pidfile-liveness to drop). Best-effort per session; reports laggards. */
|
|
227
227
|
async function stopHostedSessions(env: NodeJS.ProcessEnv, timeoutMs = 8000): Promise<{ stopped: string[]; stuck: string[] }> {
|
|
228
228
|
const identities = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
|
|
229
|
+
const { auditTermination } = await import('../supervisor/index.ts')
|
|
230
|
+
const { hostRunDir } = await import('../launch/ptyHost.ts')
|
|
231
|
+
if (identities.length) auditTermination(hostRunDir(env), identities, { reason: 'uninstall-hosted-sessions', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
|
|
229
232
|
for (const id of identities) {
|
|
230
233
|
try {
|
|
231
|
-
killPtyHost(id, env)
|
|
234
|
+
killPtyHost(id, env, { reason: 'uninstall-hosted-sessions' })
|
|
232
235
|
} catch {
|
|
233
236
|
/* verified-dead / permission — the wait below reports it */
|
|
234
237
|
}
|
|
@@ -258,6 +261,10 @@ export async function executeUninstall(opts: UninstallOptions = {}): Promise<Uni
|
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
const uid = process.getuid?.() ?? 0
|
|
264
|
+
const { auditTermination } = await import('../supervisor/index.ts')
|
|
265
|
+
const { hostRunDir } = await import('../launch/ptyHost.ts')
|
|
266
|
+
const targets = plan.items.filter(i => i.present && i.action === 'bootout-remove-plist').map(i => i.path ?? i.what)
|
|
267
|
+
if (targets.length) auditTermination(hostRunDir(env), targets, { reason: 'uninstall', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
|
|
261
268
|
// Set when a launchd job survived bootout (В56) — ~/.iapeer must NOT be removed
|
|
262
269
|
// from under a job launchd may respawn (KeepAlive) into the deleted tree.
|
|
263
270
|
let jobStillLoaded: string | undefined
|
package/src/update/index.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { IAPEER_VERSION } from '../core/version.ts'
|
|
|
39
39
|
import { iapeerBinPath, stampBinaryHealthy } from '../install/index.ts'
|
|
40
40
|
import { ensureExecutableSignature } from '../install/signature.ts'
|
|
41
41
|
import { readPeersIndex } from '../registry/index.ts'
|
|
42
|
+
import { auditTermination } from '../supervisor/index.ts'
|
|
43
|
+
import { defaultRunDir } from '../supervisor/paths.ts'
|
|
42
44
|
import {
|
|
43
45
|
cycleDaemon,
|
|
44
46
|
cycleLaunchdJob,
|
|
@@ -263,6 +265,9 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
|
|
|
263
265
|
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return []
|
|
264
266
|
const out: InfraJobRecycleResult[] = []
|
|
265
267
|
const index = readPeersIndex({ env })
|
|
268
|
+
const targets = index.peers.flatMap(peer => [...new Set([peer.runtime, ...peer.runtimes])].filter(isInfraRuntime)
|
|
269
|
+
.map(runtime => resolveAlwaysOnTarget(peer.personality, runtime, env)).filter(t => isFoundationOwnedPlist(t.path)).map(t => t.label))
|
|
270
|
+
if (targets.length) auditTermination(defaultRunDir(env), targets, { reason: 'update-recycle-infra', caller: env.PEER_IDENTITY })
|
|
266
271
|
for (const peer of index.peers) {
|
|
267
272
|
// Multi-infra (0.4.88): a personality can hold SEVERAL always-on channels
|
|
268
273
|
// (e.g. arthur: telegram on the legacy base plist + web on the per-runtime
|