@agfpd/iapeer 0.4.134 → 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/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/cli/index.ts +20 -10
- package/src/cli/listTui.ts +1 -1
- package/src/daemon/fleet.ts +2 -1
- package/src/daemon/main.ts +23 -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
|
@@ -23,11 +23,12 @@ Once a minute the daemon runs a supervisory pass. In one tick it sleeps agents i
|
|
|
23
23
|
|
|
24
24
|
## Peer states
|
|
25
25
|
|
|
26
|
-
A
|
|
26
|
+
A hosted agent is, at any moment, in one of four states:
|
|
27
27
|
|
|
28
28
|
| State | What it means | Marker in `iapeer list` |
|
|
29
29
|
|---|---|---|
|
|
30
30
|
| **alive** | the session is running and answering | `●` |
|
|
31
|
+
| **booting** | the supervisor is up, but this runtime hosting has not passed readiness | `◐` |
|
|
31
32
|
| **asleep** | no session, the peer is ready to wake on a message | `○` |
|
|
32
33
|
| **stopped** | the operator set a stop flag; the daemon won't wake it | `✕` |
|
|
33
34
|
|
|
@@ -101,6 +102,24 @@ So `stop`, `start`, `new` refuse to touch foreign launchd peers — for those th
|
|
|
101
102
|
### Process ownership at teardown
|
|
102
103
|
|
|
103
104
|
Closing a hosted session is a **hard teardown**, not the MCP client's graceful stdin/EOF shutdown.
|
|
105
|
+
Core-controlled termination first fsyncs a write-ahead `termination-request` (or a multi-target
|
|
106
|
+
`termination-batch`) to `<supervisorRunDir>/terminations.log`, with caller identity/UID, caller PID
|
|
107
|
+
and parent PID, target identity/PID/start token and reason. Failure to persist the audit refuses the
|
|
108
|
+
signal. Stop-all, uninstall and infra-update record their scope before the first termination.
|
|
109
|
+
Signals from external programs bypass this primitive: `shutdown-sigterm` in `exits.log` alone does
|
|
110
|
+
not identify their sender. The supervisor's JavaScript signal handler cannot recover that attribution.
|
|
111
|
+
|
|
112
|
+
The supervision process namespace is bound to `LifecycleConfig.supervisorRunDir`, not re-resolved
|
|
113
|
+
from an invocation's environment. A partial config without that path uses a private subdirectory of
|
|
114
|
+
its own `stateDir`. Under `IAPEER_TEST_SANDBOX=1`, the signal primitive also refuses the real host's
|
|
115
|
+
supervisor run-dir (including symlink aliases).
|
|
116
|
+
|
|
117
|
+
Readiness is specific to one hosting token. A never-ready process is not deliverable, even if its
|
|
118
|
+
TTY echoes input. The launcher tears it down on its readiness deadline; a later message can replace
|
|
119
|
+
an abandoned never-ready hosting under the wake lock. Process-control verbs can still address it.
|
|
120
|
+
Migration of existing agent sessions checks their current host emulator (or native control ping),
|
|
121
|
+
not old pane-log bytes and not merely a live PID.
|
|
122
|
+
|
|
104
123
|
Before killing the runtime, the supervisor snapshots its descendants and kills those still attached
|
|
105
124
|
to its controlling PTY, including stdio MCP launchers in separate process groups. Each PID's start
|
|
106
125
|
token and terminal are re-checked before signalling; unrelated processes and detached daemon branches
|
|
@@ -83,6 +83,7 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
|
|
|
83
83
|
- **`lifecycle.log`** — lifecycle decisions: wakes, sleeps, restarts, supervisory-tick outcomes. The first place to look if a peer is behaving wrong.
|
|
84
84
|
- **`delivery.log`** — the outcome of each delivery: who, to whom, into which runtime, the result, sizes, topic. Metadata only — **the message body is not written**.
|
|
85
85
|
- **`exits.log`** — session deaths with postmortem data: the supervisor records the cause (exit code / signal) at the moment a session dies.
|
|
86
|
+
- **`~/.iapeer/state/supervisor/terminations.log`** — strict write-ahead attribution of core-controlled termination: caller/UID, PID/parent PID, target scope/PID/start token, reason. Appended and fsynced before signalling; inability to write refuses the signal. Batch scope is recorded before stop-all, uninstall and infra-update. No entry means an external signal is unattributed, not evidence of any particular initiator. This audit follows the configured supervisor run-dir, including isolated tests.
|
|
86
87
|
|
|
87
88
|
Per-peer **pane-logs** live separately under `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — the raw terminal (pty) byte stream the supervisor appends for a session's whole life (occupancy/typing detection, the boot ready-gate, and `tail`-based monitors read them). They are rotated on the supervise tick by **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, default 16 MiB × 2): the base file keeps its path and inode (its last 8 MiB tail is retained in place, so the live writer and every reader are undisturbed) while the preceding window is promoted to `<log>.1` … `.N`. Total per peer is bounded by roughly `MAX_BYTES × (KEEP + 1)`.
|
|
88
89
|
|
package/docs/15-fleet-api.md
CHANGED
|
@@ -77,7 +77,7 @@ The full fleet state. The peer rows are produced by the **same in-process functi
|
|
|
77
77
|
|
|
78
78
|
Field notes:
|
|
79
79
|
|
|
80
|
-
- `runtimes[].status` — `live` (
|
|
80
|
+
- `runtimes[].status` — `live` (runtime passed readiness in this hosting) · `booting` (supervisor up, runtime never reached ready) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `◐` / `○` / `✕`. `booting` is not a working session or a successful delivery; clients must not display it as `live`. Process controls can still address it. Added as a status value without changing the snapshot structure.
|
|
81
81
|
- `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
|
|
82
82
|
- `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
|
|
83
83
|
- `runtimes[].model` — the `provider/model` the endpoint ACTUALLY used for its last assistant turn, as the runtime itself reports it (today `opencode`, from its state; docs/20 §Provider pinning). Absent until a first turn. Lets an operator see an implicit default provider. Additive.
|
package/docs/20-opencode.md
CHANGED
|
@@ -232,6 +232,10 @@ process was SIGKILLed before any answer, and the fresh next session never saw it
|
|
|
232
232
|
dies with the turn unfinished, the sender gets an explicit notice from iapeer
|
|
233
233
|
(«the turn was INTERRUPTED and its result is not guaranteed»), logged as
|
|
234
234
|
`ev=inflight-interrupted`.
|
|
235
|
+
Several messages injected into one unfinished hosting are summarized in one notice per original
|
|
236
|
+
sender endpoint, listing every affected delivery; different hosting tokens and senders remain
|
|
237
|
+
separate. An outbound reply alone is not a terminal boundary. Completed native turns are removed
|
|
238
|
+
from the ledger before notification grouping, so destroying a finished session emits no notice.
|
|
235
239
|
- Unknown is not failed. When a live receiver's acceptance record does not appear
|
|
236
240
|
within the confirm window (a busy claude records a queued message late), the
|
|
237
241
|
send returns `ok` with `unconfirmed: true` instead of «NOT delivered» — the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agfpd/iapeer",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.135",
|
|
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",
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"bin",
|
|
36
36
|
"docs",
|
|
37
37
|
"scripts/proof-install-signature.ts",
|
|
38
|
+
"scripts/proof-fleet-isolation.ts",
|
|
39
|
+
"scripts/proof-inflight-turns.ts",
|
|
38
40
|
"CHANGELOG.md",
|
|
39
41
|
"tsconfig.json",
|
|
40
42
|
"!src/**/*.test.ts",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Live, isolated reproduction of the 18.10 test-suite SIGTERM incident.
|
|
2
|
+
// bun scripts/proof-fleet-isolation.ts [--expect-legacy-failure]
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { startSupervisorDaemon, killSession, sessionAlive } from '../src/supervisor/index.ts'
|
|
7
|
+
import { loadLifecycleConfig, superviseTick } from '../src/lifecycle/index.ts'
|
|
8
|
+
import { upsertPeer } from '../src/registry/index.ts'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
|
|
11
|
+
const root = mkdtempSync(join(tmpdir(), 'iap-isolation-'))
|
|
12
|
+
const foreign = join(root, 'foreign')
|
|
13
|
+
const own = join(root, 'own')
|
|
14
|
+
const runDir = join(foreign, 'state/supervisor')
|
|
15
|
+
const env = { ...process.env, IAPEER_ROOT: foreign, IAPEER_LAUNCHAGENTS_DIR: join(own, 'LaunchAgents') }
|
|
16
|
+
const cfg = loadLifecycleConfig({ ...env, IAPEER_ROOT: own, IAPEER_BOOT_DEADLINE_SECS: '1', IAPEER_READY_GATE_SECS: '1' })
|
|
17
|
+
const ids = ['claude-sentinel', 'telegram-sentinel', 'notifier-sentinel']
|
|
18
|
+
const arg = (name: string) => { const i = process.argv.indexOf(name); return i < 0 ? undefined : process.argv[i + 1] }
|
|
19
|
+
const bin = arg('--bin')
|
|
20
|
+
const artifacts = arg('--artifacts')
|
|
21
|
+
const pids: Record<string, number | undefined> = {}
|
|
22
|
+
const legacyFile = fileURLToPath(new URL(`../src/lifecycle/.proof-legacy-${process.pid}.ts`, import.meta.url))
|
|
23
|
+
mkdirSync(cfg.stateDir, { recursive: true })
|
|
24
|
+
try {
|
|
25
|
+
for (const session of ids) {
|
|
26
|
+
const r = await startSupervisorDaemon({ session, runtime: session.split('-')[0]!, runDir,
|
|
27
|
+
...(bin ? { daemonArgv: [bin, 'supervisor'] } : {}),
|
|
28
|
+
serve: { argv: ['/bin/sleep', '600'], env: {}, cwd: root, exitLogPath: join(root, 'exits.log') } })
|
|
29
|
+
if (r.state !== 'started') throw new Error(JSON.stringify(r))
|
|
30
|
+
pids[session] = r.pid
|
|
31
|
+
}
|
|
32
|
+
// Exactly the problematic shape: cfg is isolated, env points at another fleet; no liveness seam.
|
|
33
|
+
let sweep = superviseTick
|
|
34
|
+
if (process.argv.includes('--replay-legacy-resolver')) {
|
|
35
|
+
// Re-run the actual sweep with ONLY its namespace selection restored to the incident's env
|
|
36
|
+
// resolution. Both fleets are created above, never supplied by the caller. Audit stays enabled.
|
|
37
|
+
const source = readFileSync(fileURLToPath(new URL('../src/lifecycle/index.ts', import.meta.url)), 'utf8')
|
|
38
|
+
const fixed = "const runDir = cfg.supervisorRunDir ?? join(cfg.stateDir, 'supervisor')"
|
|
39
|
+
if (!source.includes(fixed)) throw new Error('legacy replay patch no longer matches source')
|
|
40
|
+
writeFileSync(legacyFile, "import { hostRunDir } from '../launch/ptyHost.ts'\n" + source.replace(fixed, 'const runDir = hostRunDir(env)'))
|
|
41
|
+
sweep = (await import(legacyFile)).superviseTick
|
|
42
|
+
}
|
|
43
|
+
const outcomes = sweep(cfg, { env, nowMs: Date.now() + 3600_000 })
|
|
44
|
+
await Bun.sleep(1500)
|
|
45
|
+
const dead = ids.filter(id => !sessionAlive(runDir, id))
|
|
46
|
+
const read = (path: string) => { try { return readFileSync(path, 'utf8') } catch { return '' } }
|
|
47
|
+
const result: Record<string, unknown> = { pids, outcomes, foreignKilled: dead, exits: read(join(root, 'exits.log')) }
|
|
48
|
+
if (process.argv.includes('--expect-legacy-failure') ? dead.length !== ids.length : dead.length !== 0) throw new Error('isolation invariant failed')
|
|
49
|
+
if (bin && dead.length === 0) {
|
|
50
|
+
await upsertPeer({ personality: 'sentinel', runtime: 'claude', runtimes: ['claude', 'telegram', 'notifier'], cwd: root, intelligence: 'artificial' }, { rootDir: foreign })
|
|
51
|
+
const stop = Bun.spawn([bin, 'stop', 'sentinel', 'claude'], { env: { ...env, PEER_IDENTITY: 'test-operator' }, stdout: 'pipe', stderr: 'pipe' })
|
|
52
|
+
const code = await stop.exited
|
|
53
|
+
result.stop = { code, stdout: await new Response(stop.stdout).text(), stderr: await new Response(stop.stderr).text() }
|
|
54
|
+
await Bun.sleep(1000)
|
|
55
|
+
result.afterTargetedStop = Object.fromEntries(ids.map(id => [id, sessionAlive(runDir, id)]))
|
|
56
|
+
if (code || sessionAlive(runDir, ids[0]!) || ids.slice(1).some(id => !sessionAlive(runDir, id))) throw new Error('installed stop affected foreign endpoint')
|
|
57
|
+
}
|
|
58
|
+
result.audit = read(join(runDir, 'terminations.log'))
|
|
59
|
+
result.exits = read(join(root, 'exits.log'))
|
|
60
|
+
if (artifacts) {
|
|
61
|
+
mkdirSync(artifacts, { recursive: true })
|
|
62
|
+
writeFileSync(join(artifacts, 'result.json'), JSON.stringify(result, null, 2) + '\n')
|
|
63
|
+
}
|
|
64
|
+
console.log(JSON.stringify(result, null, 2))
|
|
65
|
+
console.log(dead.length ? 'REPRODUCED: cfg-only sandbox killed foreign supervisors' : 'PASS: foreign supervisors survived the real sweep')
|
|
66
|
+
} finally {
|
|
67
|
+
for (const id of ids) killSession(runDir, id)
|
|
68
|
+
await Bun.sleep(1000)
|
|
69
|
+
rmSync(root, { recursive: true, force: true })
|
|
70
|
+
rmSync(legacyFile, { force: true })
|
|
71
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Real OpenCode + synthetic streaming provider, isolated root. No cloud calls or fleet traffic.
|
|
2
|
+
// OPENCODE_BIN=... bun scripts/proof-inflight-turns.ts --seed-config <opencode config dir> [--keep]
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
const bin = process.env.OPENCODE_BIN
|
|
6
|
+
if (!bin || !existsSync(bin)) throw new Error('set OPENCODE_BIN')
|
|
7
|
+
const root = mkdtempSync('/tmp/iap-inflight-live-')
|
|
8
|
+
const home = join(root, 'home')
|
|
9
|
+
const env = { PATH: process.env.PATH, HOME: home, IAPEER_ROOT: join(root, 'iapeer'), IAPEER_PORT: '9',
|
|
10
|
+
IAPEER_OPENCODE_BIN: bin, XDG_CONFIG_HOME: join(home, 'config'), XDG_DATA_HOME: join(home, 'data'),
|
|
11
|
+
XDG_CACHE_HOME: join(home, 'cache'), XDG_STATE_HOME: join(home, 'state'),
|
|
12
|
+
OPENCODE_DISABLE_DEFAULT_PLUGINS: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true', HOMEBREW_NO_AUTO_UPDATE: '1' }
|
|
13
|
+
mkdirSync(home, { recursive: true })
|
|
14
|
+
const seed = process.argv.indexOf('--seed-config')
|
|
15
|
+
if (seed >= 0) cpSync(process.argv[seed + 1]!, join(home, 'config/opencode'), { recursive: true })
|
|
16
|
+
for (const k of Object.keys(process.env)) delete process.env[k]
|
|
17
|
+
Object.assign(process.env, env)
|
|
18
|
+
const { initPeer } = await import('../src/init/index.ts')
|
|
19
|
+
const { loadLifecycleConfig, wakeOrSpawn } = await import('../src/lifecycle/index.ts')
|
|
20
|
+
const { routeSend, deliveryProcessed, deliveryCompleted } = await import('../src/transport/index.ts')
|
|
21
|
+
const { makeNoteInflight, processInflight } = await import('../src/daemon/main.ts')
|
|
22
|
+
const { readInflight } = await import('../src/lifecycle/inflight.ts')
|
|
23
|
+
const { opencodePaths } = await import('../src/opencode/config.ts')
|
|
24
|
+
const { killPtyHost, hostSessionAlive } = await import('../src/launch/ptyHost.ts')
|
|
25
|
+
const { ok } = await import('../src/core/errors.ts')
|
|
26
|
+
const cfg = loadLifecycleConfig(env)
|
|
27
|
+
const personality = 'turn-proof', identity = `opencode-${personality}`, cwd = join(root, personality)
|
|
28
|
+
mkdirSync(cwd, { recursive: true })
|
|
29
|
+
let hanging = false
|
|
30
|
+
const provider = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
|
|
31
|
+
await req.json()
|
|
32
|
+
const chunk = (delta: unknown, finish: string | null) => 'data: ' + JSON.stringify({ id: 'proof', object: 'chat.completion.chunk', created: 1, model: 'synthetic', choices: [{ index: 0, delta, finish_reason: finish }] }) + '\n\n'
|
|
33
|
+
const initial = chunk({ role: 'assistant', content: hanging ? 'STILL WORKING' : 'FINISHED' }, null)
|
|
34
|
+
return new Response(hanging ? new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(initial)) } }) : initial + chunk({}, 'stop') + 'data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream' } })
|
|
35
|
+
} })
|
|
36
|
+
const notices: string[] = []
|
|
37
|
+
const sink = (async (_caller: unknown, input: { message: string; personality: string }) => {
|
|
38
|
+
notices.push(input.message)
|
|
39
|
+
return ok({ ok: true, delivered_to: { personality: input.personality, runtime: 'codex' }, woke: false, ts: 'x' })
|
|
40
|
+
}) as never
|
|
41
|
+
const until = async (what: string, f: () => boolean) => {
|
|
42
|
+
for (let i = 0; i < 300; i++) { if (f()) return; await Bun.sleep(100) }
|
|
43
|
+
throw new Error(`timeout: ${what}`)
|
|
44
|
+
}
|
|
45
|
+
const snapshots: unknown[] = []
|
|
46
|
+
let success = false
|
|
47
|
+
try {
|
|
48
|
+
await initPeer({ cwd, runtime: 'opencode', personality, intelligence: 'artificial', env })
|
|
49
|
+
const paths = opencodePaths(personality, { env })
|
|
50
|
+
mkdirSync(paths.dir, { recursive: true })
|
|
51
|
+
writeFileSync(paths.config, JSON.stringify({ model: 'synthetic/synthetic', small_model: 'synthetic/synthetic', enabled_providers: ['synthetic'], provider: {
|
|
52
|
+
synthetic: { npm: '@ai-sdk/openai-compatible', name: 'Synthetic', options: { baseURL: provider.url.toString() + 'v1', apiKey: 'x' }, models: { synthetic: { name: 'Synthetic', limit: { context: 100000, output: 1000 } } } },
|
|
53
|
+
} }))
|
|
54
|
+
const caller = { personality: 'sender', runtime: 'codex', address: 'codex-sender', intelligence: 'artificial', cwd, description: '', record: { personality: 'sender', runtime: 'codex', runtimes: ['codex'], intelligence: 'artificial', cwd } } as never
|
|
55
|
+
const deps = { env, wake: (args: never) => wakeOrSpawn(args, { cfg, env }), noteInflight: makeNoteInflight(cfg) }
|
|
56
|
+
const send = async (message: string) => {
|
|
57
|
+
const r = await routeSend(caller, { personality, runtime: 'opencode', message }, deps)
|
|
58
|
+
if (!r.ok) throw r.error
|
|
59
|
+
return r.value
|
|
60
|
+
}
|
|
61
|
+
snapshots.push({ phase: 'finished-send', ack: await send('FINISHED_TURN_PROOF') })
|
|
62
|
+
await until('native terminal record', () => readInflight(cfg.stateDir, identity).some(e => deliveryCompleted(e, env)))
|
|
63
|
+
snapshots.push({ phase: 'native-completed', entries: readInflight(cfg.stateDir, identity).map(e => ({ id: e.id, processed: deliveryProcessed(e, env), completed: deliveryCompleted(e, env) })) })
|
|
64
|
+
// Do not sweep before killing: completion must still be recognized after the receiver is gone.
|
|
65
|
+
killPtyHost(identity, env, { reason: 'proof-kill-after-native-completion' })
|
|
66
|
+
await until('finished receiver gone', () => !hostSessionAlive(identity, env))
|
|
67
|
+
await processInflight(cfg, env, deps, { send: sink })
|
|
68
|
+
if (notices.length || readInflight(cfg.stateDir, identity).length) throw new Error('finished turn generated notice or retained ledger')
|
|
69
|
+
snapshots.push({ phase: 'kill-after-finish', noticeCount: notices.length })
|
|
70
|
+
|
|
71
|
+
hanging = true
|
|
72
|
+
snapshots.push({ phase: 'mid-send', ack: await send('UNFINISHED_TURN_PROOF') })
|
|
73
|
+
await until('native assistant starts without terminal', () => readInflight(cfg.stateDir, identity).some(e => deliveryProcessed(e, env) && !deliveryCompleted(e, env)))
|
|
74
|
+
snapshots.push({ phase: 'native-unfinished', entries: readInflight(cfg.stateDir, identity).map(e => ({ id: e.id, processed: deliveryProcessed(e, env), completed: deliveryCompleted(e, env) })) })
|
|
75
|
+
killPtyHost(identity, env, { reason: 'proof-kill-during-native-stream' })
|
|
76
|
+
await until('unfinished receiver gone', () => !hostSessionAlive(identity, env))
|
|
77
|
+
await processInflight(cfg, env, deps, { send: sink })
|
|
78
|
+
await processInflight(cfg, env, deps, { send: sink })
|
|
79
|
+
if (notices.length !== 1 || !notices[0]!.includes('INTERRUPTED')) throw new Error(`expected one notice, got ${notices.length}`)
|
|
80
|
+
snapshots.push({ phase: 'kill-mid-turn-two-sweeps', noticeCount: notices.length, notices })
|
|
81
|
+
success = true
|
|
82
|
+
} finally {
|
|
83
|
+
killPtyHost(identity, env, { reason: 'proof-cleanup' })
|
|
84
|
+
provider.stop(true)
|
|
85
|
+
writeFileSync(join(root, 'result.json'), JSON.stringify({ success, codeSource: import.meta.url, snapshots }, null, 2))
|
|
86
|
+
console.log(JSON.stringify({ root, success, snapshots }, null, 2))
|
|
87
|
+
await Bun.sleep(300)
|
|
88
|
+
if (success && !process.argv.includes('--keep')) rmSync(root, { recursive: true, force: true })
|
|
89
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -26,9 +26,11 @@ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaem
|
|
|
26
26
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
27
27
|
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
28
28
|
import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
|
|
29
|
+
import { auditTermination } from '../supervisor/index.ts'
|
|
29
30
|
import {
|
|
30
31
|
compactDoneBaseline,
|
|
31
32
|
isPeerLive,
|
|
33
|
+
isPeerReady,
|
|
32
34
|
purgeAttachmentInbox,
|
|
33
35
|
routeControl,
|
|
34
36
|
routeSend,
|
|
@@ -106,7 +108,9 @@ function infraRecycleFailed(infra: readonly { state: string }[] | undefined): bo
|
|
|
106
108
|
// list — registry + per-runtime liveness (contract Примитивы §list)
|
|
107
109
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
110
|
|
|
109
|
-
|
|
111
|
+
/** `booting` — the process is up but its runtime never reached its ready surface (yet, or ever: a
|
|
112
|
+
* process hung before main, 18.10). Not deliverable; not ●. */
|
|
113
|
+
export type RuntimeLiveness = 'live' | 'booting' | 'asleep' | 'stopped'
|
|
110
114
|
export interface RuntimeStatus {
|
|
111
115
|
runtime: Runtime
|
|
112
116
|
status: RuntimeLiveness
|
|
@@ -145,9 +149,11 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
145
149
|
return index.peers.map(peer => {
|
|
146
150
|
const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
|
|
147
151
|
runtime: rt,
|
|
148
|
-
status:
|
|
152
|
+
status: isPeerReady(rt, peer.personality, env)
|
|
149
153
|
? 'live'
|
|
150
|
-
:
|
|
154
|
+
: isPeerLive(rt, peer.personality, cfg.sockDir, env)
|
|
155
|
+
? 'booting'
|
|
156
|
+
: isStopped(cfg, buildProcessAddress(rt, peer.personality))
|
|
151
157
|
? 'stopped'
|
|
152
158
|
: 'asleep',
|
|
153
159
|
}))
|
|
@@ -177,7 +183,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
177
183
|
})
|
|
178
184
|
}
|
|
179
185
|
|
|
180
|
-
const GLYPH: Record<RuntimeLiveness, string> = { live: '●', asleep: '○', stopped: '✕' }
|
|
186
|
+
const GLYPH: Record<RuntimeLiveness, string> = { live: '●', booting: '◐', asleep: '○', stopped: '✕' }
|
|
181
187
|
|
|
182
188
|
/** Render the scriptable list table (non-tty default). Columns are PADDED to align
|
|
183
189
|
* (name · [default] · nature · liveness) — a ragged table reads as noise. The
|
|
@@ -260,7 +266,9 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
260
266
|
return [{ personality, runtime: peer.runtime, action: 'refused-foreign-launchd', reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not stop it` }]
|
|
261
267
|
}
|
|
262
268
|
const out: StopStartOutcome[] = []
|
|
263
|
-
|
|
269
|
+
const targets = targetRuntimes(peer, runtime)
|
|
270
|
+
auditTermination(hostRunDir(env), targets.map(rt => buildProcessAddress(rt, personality)), { reason: 'stop', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
|
|
271
|
+
for (const rt of targets) {
|
|
264
272
|
const identity = buildProcessAddress(rt, personality)
|
|
265
273
|
const sock = buildSocketPath(rt, personality, cfg.sockDir)
|
|
266
274
|
if (isInfraRuntime(rt)) {
|
|
@@ -276,7 +284,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
276
284
|
// below still tears the session down.
|
|
277
285
|
const target = resolveAlwaysOnTarget(personality, rt, env)
|
|
278
286
|
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${target.label}`], { encoding: 'utf8' })
|
|
279
|
-
killSession(sock, identity, env)
|
|
287
|
+
killSession(sock, identity, env, 'stop')
|
|
280
288
|
const stderrText = (r.stderr ?? '').trim()
|
|
281
289
|
const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
|
|
282
290
|
const reason = benign ? undefined : `launchctl bootout exited ${r.status}${stderrText ? `: ${stderrText}` : ''}`
|
|
@@ -290,7 +298,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
290
298
|
// no reaped-gone death class for a state the daemon knows 100%).
|
|
291
299
|
setStopped(cfg, identity)
|
|
292
300
|
setIdleReaped(cfg, identity)
|
|
293
|
-
killSession(sock, identity, env)
|
|
301
|
+
killSession(sock, identity, env, 'stop')
|
|
294
302
|
removeSessionState(cfg, identity)
|
|
295
303
|
out.push({ personality, runtime: rt, action: 'stopped' })
|
|
296
304
|
// Durable trace (fleet-API follow-up): the verb was SILENT in lifecycle.log — a
|
|
@@ -674,7 +682,7 @@ export async function newPeer(
|
|
|
674
682
|
clearStopped(cfg, identity)
|
|
675
683
|
clearIdleReaped(cfg, identity)
|
|
676
684
|
clearNewEager(cfg, identity)
|
|
677
|
-
killSession(sock, identity, env) // canary-clean deliberate teardown; no-op when dead
|
|
685
|
+
killSession(sock, identity, env, 'new') // canary-clean deliberate teardown; no-op when dead
|
|
678
686
|
removeSessionState(cfg, identity) // never a death class — supervise stays silent
|
|
679
687
|
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
680
688
|
const r = await wake({ personality, runtime: rt, task: '', resume: false })
|
|
@@ -961,7 +969,7 @@ export async function removeRuntimeCli(
|
|
|
961
969
|
return { personality, runtime, action: 'refused-live', reason: `${identity} is LIVE — stop it first (iapeer stop ${personality} ${runtime}) or pass --force` }
|
|
962
970
|
}
|
|
963
971
|
const { killPtyHost } = await import('../launch/ptyHost.ts')
|
|
964
|
-
killPtyHost(identity, env)
|
|
972
|
+
killPtyHost(identity, env, { reason: 'remove-runtime-force' })
|
|
965
973
|
steps.push('live session stopped (--force)')
|
|
966
974
|
}
|
|
967
975
|
// Slots for THIS endpoint (provider + capabilities), while the peer's state is still consistent.
|
|
@@ -1114,6 +1122,7 @@ export async function removePeerCli(
|
|
|
1114
1122
|
if (env.IAPEER_TEST_SANDBOX === '1') {
|
|
1115
1123
|
bootoutNote = 'skipped-sandbox'
|
|
1116
1124
|
} else {
|
|
1125
|
+
auditTermination(hostRunDir(env), [entry.label], { reason: 'remove', caller: env.PEER_IDENTITY })
|
|
1117
1126
|
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${entry.label}`], { encoding: 'utf8' })
|
|
1118
1127
|
// bootout exits non-zero when the job was already unloaded — benign for a teardown.
|
|
1119
1128
|
bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
|
|
@@ -2431,6 +2440,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2431
2440
|
? [positionals[0]]
|
|
2432
2441
|
: null
|
|
2433
2442
|
if (!peers) return argErr(errOut, 'stop needs a peer or --all — usage: iapeer stop <peer> [runtime] | --all')
|
|
2443
|
+
if (flags.all === true) auditTermination(hostRunDir(env), peers, { reason: 'stop-all', caller: env.PEER_IDENTITY, sandbox: env.IAPEER_TEST_SANDBOX === '1' })
|
|
2434
2444
|
const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
|
|
2435
2445
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
2436
2446
|
// В48 — a REAL bootout error (reason set; benign already-unloaded carries none) is exit 1:
|
|
@@ -3022,7 +3032,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
3022
3032
|
}
|
|
3023
3033
|
out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
|
|
3024
3034
|
const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
|
|
3025
|
-
killSession(sock, identity, env)
|
|
3035
|
+
killSession(sock, identity, env, 'self-fresh')
|
|
3026
3036
|
return 0
|
|
3027
3037
|
}
|
|
3028
3038
|
case 'interrupt': {
|
package/src/cli/listTui.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the su
|
|
|
12
12
|
import { runSupervisorClient } from '../supervisor/client.ts'
|
|
13
13
|
import { listPeers, type PeerListing } from './index.ts'
|
|
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
|
// ─── pure render ─────────────────────────────────────────────────────────────
|
|
18
18
|
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -82,7 +82,8 @@ export const FLEET_API_VERSION = 1
|
|
|
82
82
|
|
|
83
83
|
export interface FleetRuntimeStatus {
|
|
84
84
|
runtime: string
|
|
85
|
-
|
|
85
|
+
/** `booting` — process up, runtime not (yet / ever) ready: not deliverable (18.10). */
|
|
86
|
+
status: 'live' | 'booting' | 'asleep' | 'stopped'
|
|
86
87
|
/** AGENT-ENDPOINT discovery (docs/15 §Agent endpoints): true iff this declared runtime is an
|
|
87
88
|
* agent runtime THIS core can host (`host.agent_runtimes`). A channel offers exactly the
|
|
88
89
|
* `agent:true` runtimes of a personality — never a consumer-side hardcode or `Bun.which`.
|
package/src/daemon/main.ts
CHANGED
|
@@ -336,9 +336,22 @@ export async function processInflight(
|
|
|
336
336
|
// A turn the session STARTED on a message but did not finish before dying: never redelivered (its side
|
|
337
337
|
// effects may already have happened) — the sender is told explicitly that the result is not guaranteed.
|
|
338
338
|
for (const identity of listInterruptedIdentities(cfg.stateDir)) {
|
|
339
|
+
// Multiple messages can be injected into ONE still-running turn. A session death must notify each
|
|
340
|
+
// affected sender once, not fan out one alert per injection (18.10: twenty alerts from one turn).
|
|
341
|
+
// Keep different hosting tokens and callers separate; no message is silently discarded: the one
|
|
342
|
+
// notice enumerates the affected deliveries. Completed entries have already left in the sweep.
|
|
343
|
+
const groups = new Map<string, InflightEntry[]>()
|
|
339
344
|
for (const e of takeInterrupted(cfg.stateDir, identity)) {
|
|
340
|
-
const
|
|
341
|
-
|
|
345
|
+
const key = JSON.stringify([e.sessionToken, e.caller.runtime, e.caller.personality])
|
|
346
|
+
const entries = groups.get(key) ?? []
|
|
347
|
+
entries.push(e)
|
|
348
|
+
groups.set(key, entries)
|
|
349
|
+
}
|
|
350
|
+
for (const entries of groups.values()) {
|
|
351
|
+
const e = entries[0]!
|
|
352
|
+
const summary = entries.length > 1 ? `\nAffected deliveries (${entries.length}):\n` + entries.map(x => `${x.sentAt}${x.topic ? ` topic=${x.topic}` : ''}: ${x.message.split('\n')[0]!.slice(0, 200)}`).join('\n') : ''
|
|
353
|
+
const notified = await notifySender(e, interruptedNotice(e) + summary)
|
|
354
|
+
appendDeliveryEvent(cfg.eventLogDir, { ev: 'inflight-interrupted', caller: `${e.caller.runtime}-${e.caller.personality}`, to: e.personality, rt: e.runtime, ok: 'false', notified, count: entries.length })
|
|
342
355
|
}
|
|
343
356
|
}
|
|
344
357
|
for (const identity of listOrphanIdentities(cfg.stateDir)) {
|
|
@@ -470,6 +483,14 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
470
483
|
// Notice board (docs/19) — ONE instance shared by the mute-watch (raises) and the
|
|
471
484
|
// fleet handler (serves /notices + the snapshot field). Constructed HERE, at the
|
|
472
485
|
// composition point, because that sharing is the whole wiring.
|
|
486
|
+
// Readiness marker compatibility (18.10): sessions already running when this core first starts were
|
|
487
|
+
// launched without a ready marker — stamp them ONCE so delivery does not read the healthy fleet as
|
|
488
|
+
// booting. Every later hosting writes its own marker at its ready-gate.
|
|
489
|
+
try {
|
|
490
|
+
const { backfillHostReadyOnce } = await import('../launch/ptyHost.ts')
|
|
491
|
+
const stamped = await backfillHostReadyOnce(env)
|
|
492
|
+
if (stamped) appendLifecycleEvent(cfg.eventLogDir, { ev: 'ready-backfill', count: stamped.length }, { env })
|
|
493
|
+
} catch { /* best-effort */ }
|
|
473
494
|
const board = new NoticeBoard({ logDir: cfg.eventLogDir, env })
|
|
474
495
|
const stopMuteWatch = startMuteWatch(cfg, board, {
|
|
475
496
|
env,
|
package/src/launch/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { existsSync, mkdirSync, statSync } from 'fs'
|
|
|
17
17
|
import { dirname } from 'path'
|
|
18
18
|
import { type Runtime } from '../core/constants.ts'
|
|
19
19
|
import { buildLaunchInvocation } from './invocation.ts'
|
|
20
|
-
import { deliverHosted, hostSessionAlive, startPtyHost, waitHostReady } from './ptyHost.ts'
|
|
20
|
+
import { deliverHosted, hostSessionAlive, hostSessionToken, killPtyHost, markHostReady, startPtyHost, waitHostReady } from './ptyHost.ts'
|
|
21
21
|
import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
|
|
22
22
|
import { claudeAdapter } from './adapters/claude.ts'
|
|
23
23
|
import { codexAdapter } from './adapters/codex.ts'
|
|
@@ -236,15 +236,24 @@ export const launch: LaunchFn = async (
|
|
|
236
236
|
// router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
|
|
237
237
|
// no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
|
|
238
238
|
if (adapter.kind === 'router') {
|
|
239
|
-
if (hostSessionAlive(identity, env))
|
|
239
|
+
if (hostSessionAlive(identity, env)) {
|
|
240
|
+
markHostReady(identity, env) // a router is up the instant its child serves
|
|
241
|
+
return ready(identity)
|
|
242
|
+
}
|
|
240
243
|
return fail(identity, 'hosted router session not alive immediately after bringup')
|
|
241
244
|
}
|
|
245
|
+
const hostingToken = hostSessionToken(identity, env)
|
|
242
246
|
// STRUCTURAL runtime (opencode): readiness = the runtime's own ready record for THIS launch, and
|
|
243
247
|
// the first message goes through the adapter's deliver, whose ack means THIS message is persisted
|
|
244
248
|
// natively (a durable native queue) — so there is no pane gate and no activity ready-gate.
|
|
245
249
|
if (adapter.structuralReady && readyToken) {
|
|
246
250
|
const r = await adapter.structuralReady(spec, readyToken, Date.now() + cfg.bootDeadlineSecs * 1000, () => hostSessionAlive(identity, env), env)
|
|
247
|
-
if (!r.ok)
|
|
251
|
+
if (!r.ok) {
|
|
252
|
+
// A bring-up that never reached ready must not linger as a live-looking host (incident 18.10).
|
|
253
|
+
killPtyHost(identity, env, { reason: 'launch-structural-never-ready' })
|
|
254
|
+
return fail(identity, `never-became-ready (structural: ${r.reason ?? 'unknown'})`)
|
|
255
|
+
}
|
|
256
|
+
markHostReady(identity, env, hostingToken)
|
|
248
257
|
for (const w of r.warnings ?? []) {
|
|
249
258
|
if (!loggedWarnings.has(w)) appendLifecycleEvent(cfg.exitLogDir, { ev: 'launch-warning', identity, warning: w }, { env })
|
|
250
259
|
}
|
|
@@ -261,7 +270,15 @@ export const launch: LaunchFn = async (
|
|
|
261
270
|
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, approvalMode: spec.approvalMode, env },
|
|
262
271
|
sleep,
|
|
263
272
|
)
|
|
264
|
-
if (!isReady)
|
|
273
|
+
if (!isReady) {
|
|
274
|
+
// Tear the host down: a process that never painted its input surface (hung before main —
|
|
275
|
+
// codex in dyld, 18.10) otherwise lived on under a healthy supervisor, read as ● by list and
|
|
276
|
+
// «live» by delivery, and swallowed a message with a false ok. Gone, the ledger redelivers its
|
|
277
|
+
// entries to the next session.
|
|
278
|
+
killPtyHost(identity, env, { reason: 'launch-never-ready' })
|
|
279
|
+
return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
|
|
280
|
+
}
|
|
281
|
+
markHostReady(identity, env, hostingToken)
|
|
265
282
|
if (firstMessage.trim().length === 0) return ready(identity) // bare bring-up — no message to deliver
|
|
266
283
|
// В18 — snap the ready-gate baseline AFTER boot, not before. newestActivityMtime is a raw
|
|
267
284
|
// transcript-file mtime that BOOT ITSELF advances: codex writes its rollout-jsonl on start,
|
package/src/launch/launchd.ts
CHANGED
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
import { buildProcessAddress } from '../core/socket.ts'
|
|
32
32
|
import { peerLogsDir } from '../storage/index.ts'
|
|
33
33
|
import { IapError } from '../core/errors.ts'
|
|
34
|
+
import { auditTermination } from '../supervisor/index.ts'
|
|
35
|
+
import { defaultRunDir } from '../supervisor/paths.ts'
|
|
34
36
|
|
|
35
37
|
const DEFAULT_THROTTLE_SECS = 10
|
|
36
38
|
|
|
@@ -556,6 +558,7 @@ export function cycleLaunchdJob(
|
|
|
556
558
|
const uid = currentUid()
|
|
557
559
|
return cycleLaunchdJobCore(uid, label, plistPath, {
|
|
558
560
|
run: args => {
|
|
561
|
+
if (args[0] === 'bootout') auditTermination(defaultRunDir(env), [label], { reason: 'launchd-cycle', caller: env.PEER_IDENTITY })
|
|
559
562
|
const r = spawnSync('launchctl', args, { encoding: 'utf8' })
|
|
560
563
|
return { status: r.status, stderr: r.stderr ?? '' }
|
|
561
564
|
},
|
|
@@ -610,6 +613,7 @@ export function bootoutLaunchdJob(label: string, env: NodeJS.ProcessEnv = proces
|
|
|
610
613
|
}
|
|
611
614
|
const uid = currentUid()
|
|
612
615
|
if (!isLaunchdLoaded(label, uid)) return { state: 'not-loaded' }
|
|
616
|
+
auditTermination(defaultRunDir(env), [label], { reason: 'launchd-bootout', caller: env.PEER_IDENTITY })
|
|
613
617
|
const r = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' })
|
|
614
618
|
return r.status === 0 ? { state: 'booted-out' } : { state: 'failed', detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
|
|
615
619
|
}
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -165,7 +165,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
165
165
|
// kill the supervisor daemon (SIGTERM → hard teardown of its PTY-owned tree + sock/pid/serve cleanup)
|
|
166
166
|
// so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
|
|
167
167
|
// (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
|
|
168
|
-
if (stop) killPtyHost(identity, env)
|
|
168
|
+
if (stop) killPtyHost(identity, env, { reason: 'run-infra-shutdown' })
|
|
169
169
|
return 0
|
|
170
170
|
}
|
|
171
171
|
|
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
|