@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
|
@@ -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
|
package/docs/12-cli-reference.md
CHANGED
|
@@ -77,7 +77,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
77
77
|
|---|---|
|
|
78
78
|
| `enable <plugin> [peer] [--no-setup]` | Install and enable a capability plugin for a peer (across runtimes), run its setup step. |
|
|
79
79
|
| `native-memory <off\|on> (--peer <p> \| --all)` | Turn off/restore a peer's runtimes' native memory. |
|
|
80
|
-
| `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. |
|
|
80
|
+
| `trust-hooks <hooks.json> [--check]` | Pre-trust codex hooks from a file (without the modal). `--check` — a divergence report. The core also re-asserts the trust of `<cwd>/.codex/hooks.json` on EVERY codex launch (idempotent), and `rename` / `approval-mode yolo` restore what must stay trusted — a codex peer boots to its input with no human even after its trust was wiped (18.10). |
|
|
81
81
|
|
|
82
82
|
## Agent self-calls
|
|
83
83
|
|
|
@@ -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/approval/install.ts
CHANGED
|
@@ -161,6 +161,10 @@ export function removeCodexApproval(cwd: string, env: NodeJS.ProcessEnv = proces
|
|
|
161
161
|
return path
|
|
162
162
|
}
|
|
163
163
|
writeFileAtomic(path, `${JSON.stringify(obj, null, 2)}\n`, 0o644)
|
|
164
|
+
// The trust wipe above is cwd-wide (codex keys state POSITIONALLY, so removing our group shifts the
|
|
165
|
+
// others' keys). Re-seed whatever hooks REMAIN — the memory provider's among them — or the next codex
|
|
166
|
+
// boot stops at «hooks need review» (incident 18.10).
|
|
167
|
+
preSeedCodexHooksTrust(path, env)
|
|
164
168
|
return path
|
|
165
169
|
}
|
|
166
170
|
|
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()}` : ''})`
|
|
@@ -1439,9 +1448,13 @@ export async function renamePeerCli(
|
|
|
1439
1448
|
const { preTrustCodexCwd, removeCodexCwdTrust } = await import('../launch/nativeMemory.ts')
|
|
1440
1449
|
preTrustCodexCwd(newCwd, env)
|
|
1441
1450
|
removeCodexCwdTrust(oldCwd, env)
|
|
1442
|
-
const { removeCodexHooksTrustUnder } = await import('../launch/codexHooksTrust.ts')
|
|
1451
|
+
const { removeCodexHooksTrustUnder, preSeedCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
|
|
1443
1452
|
removeCodexHooksTrustUnder(oldCwd, env)
|
|
1444
|
-
|
|
1453
|
+
// Hooks trust is keyed by the hooks.json PATH, so it must move with the cwd — without this a
|
|
1454
|
+
// rename round-trip (a→b→a) wiped it entirely and codex stopped at «hooks need review» (18.10).
|
|
1455
|
+
const newHooks = join(newCwd, '.codex', 'hooks.json')
|
|
1456
|
+
if (existsSync(newHooks)) preSeedCodexHooksTrust(newHooks, env)
|
|
1457
|
+
sideEffects.push('codex-trust: re-trusted new cwd (+ hooks) + cleaned old')
|
|
1445
1458
|
} catch (e) {
|
|
1446
1459
|
sideEffects.push(`codex-trust: failed (${e instanceof Error ? e.message : String(e)})`)
|
|
1447
1460
|
}
|
|
@@ -2427,6 +2440,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2427
2440
|
? [positionals[0]]
|
|
2428
2441
|
: null
|
|
2429
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' })
|
|
2430
2444
|
const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
|
|
2431
2445
|
for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
|
|
2432
2446
|
// В48 — a REAL bootout error (reason set; benign already-unloaded carries none) is exit 1:
|
|
@@ -3018,7 +3032,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
3018
3032
|
}
|
|
3019
3033
|
out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
|
|
3020
3034
|
const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
|
|
3021
|
-
killSession(sock, identity, env)
|
|
3035
|
+
killSession(sock, identity, env, 'self-fresh')
|
|
3022
3036
|
return 0
|
|
3023
3037
|
}
|
|
3024
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,
|
|
@@ -41,10 +41,11 @@
|
|
|
41
41
|
|
|
42
42
|
import { homedir } from 'os'
|
|
43
43
|
import { join } from 'path'
|
|
44
|
-
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
44
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
45
|
+
import { preSeedCodexHooksTrust } from '../codexHooksTrust.ts'
|
|
45
46
|
import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
46
47
|
import { detectNumberedModal } from './modalDetect.ts'
|
|
47
|
-
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
48
|
+
import type { ApprovalMode, ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, PreparedLaunch, RuntimeAdapter } from '../types.ts'
|
|
48
49
|
|
|
49
50
|
/**
|
|
50
51
|
* The `/goal` subcommands we will type into a live codex session (see executeControl → 'goal').
|
|
@@ -170,6 +171,27 @@ export const codexAdapter: RuntimeAdapter = {
|
|
|
170
171
|
kind: 'tui',
|
|
171
172
|
usesDoctrine: true,
|
|
172
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Hooks trust, re-asserted on EVERY launch (incident 18.10: oc-test-nemo/codex stopped at «PostToolUse
|
|
176
|
+
* hooks — 1 hook needs review» after a rename round-trip had wiped its `[hooks.state]` — a fresh codex
|
|
177
|
+
* that meets an untrusted hook waits for a human forever). The invariant «a codex peer boots to its
|
|
178
|
+
* input with no human» therefore lives HERE, not only at provision time: whatever erased the trust
|
|
179
|
+
* (rename, an approval-mode flip, a hand edit of config.toml), the next launch restores it. The peer's
|
|
180
|
+
* `.codex/hooks.json` is operator/provider-owned configuration, trusted like its `.mcp.json`.
|
|
181
|
+
* Idempotent (no write when the hashes already match); a failure is a launch WARNING, never a block —
|
|
182
|
+
* the modal is still better than no session, and the warning names it.
|
|
183
|
+
*/
|
|
184
|
+
prepareLaunch(spec, _baseEnv, cfg): PreparedLaunch {
|
|
185
|
+
const env = cfg.env ?? process.env
|
|
186
|
+
const hooks = join(spec.cwd, '.codex', 'hooks.json')
|
|
187
|
+
const warnings: string[] = []
|
|
188
|
+
if (existsSync(hooks)) {
|
|
189
|
+
const t = preSeedCodexHooksTrust(hooks, env)
|
|
190
|
+
if (t.state === 'failed') warnings.push(`codex hooks trust not seeded (${t.detail ?? 'unknown'}) — a review modal may block the boot`)
|
|
191
|
+
}
|
|
192
|
+
return { env: {}, ...(warnings.length ? { warnings } : {}) }
|
|
193
|
+
},
|
|
194
|
+
|
|
173
195
|
/**
|
|
174
196
|
* Delivery markers for submitIntoTui (docs/Рантайм codex "Доставка"):
|
|
175
197
|
* - promptGlyphs ['›'] — codex's input-row arrow (the same stable invariant as
|
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
|
|