@agfpd/iapeer 0.4.32 → 0.4.34
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/package.json +1 -1
- package/src/cli/index.ts +8 -8
- package/src/launch/adapters/codex.ts +4 -22
- package/src/launch/adapters/transcriptTail.ts +67 -0
- package/src/launch/index.ts +5 -4
- package/src/launch/launchdRun.ts +2 -2
- package/src/launch/ptyHost.ts +28 -22
- package/src/lifecycle/index.ts +35 -20
- package/src/lifecycle/queue.ts +29 -2
- package/src/storage/rotatelog.ts +0 -0
- package/src/supervisor/index.ts +16 -6
- package/src/transport/index.ts +49 -30
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -129,7 +129,7 @@ export function listPeers(opts: CliEnvOptions = {}): PeerListing[] {
|
|
|
129
129
|
return index.peers.map(peer => {
|
|
130
130
|
const runtimes: RuntimeStatus[] = peer.runtimes.map(rt => ({
|
|
131
131
|
runtime: rt,
|
|
132
|
-
status: isPeerLive(rt, peer.personality, cfg.sockDir)
|
|
132
|
+
status: isPeerLive(rt, peer.personality, cfg.sockDir, env)
|
|
133
133
|
? 'live'
|
|
134
134
|
: isStopped(cfg, buildProcessAddress(rt, peer.personality))
|
|
135
135
|
? 'stopped'
|
|
@@ -248,7 +248,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
248
248
|
// the service was already not loaded — benign for stop — but surface the detail in
|
|
249
249
|
// the reason rather than silently claiming success.)
|
|
250
250
|
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
|
|
251
|
-
killSession(sock, identity)
|
|
251
|
+
killSession(sock, identity, env)
|
|
252
252
|
out.push({ personality, runtime: rt, action: 'bootout', reason: r.status === 0 ? undefined : `launchctl bootout exited ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''}` })
|
|
253
253
|
} else {
|
|
254
254
|
// A deliberate stop is a CLEAN PARK, not a death (stop→start must survive ≥
|
|
@@ -258,7 +258,7 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
|
|
|
258
258
|
// no reaped-gone death class for a state the daemon knows 100%).
|
|
259
259
|
setStopped(cfg, identity)
|
|
260
260
|
setIdleReaped(cfg, identity)
|
|
261
|
-
killSession(sock, identity)
|
|
261
|
+
killSession(sock, identity, env)
|
|
262
262
|
removeSessionState(cfg, identity)
|
|
263
263
|
out.push({ personality, runtime: rt, action: 'stopped' })
|
|
264
264
|
}
|
|
@@ -551,7 +551,7 @@ export async function newPeer(
|
|
|
551
551
|
clearStopped(cfg, identity)
|
|
552
552
|
clearIdleReaped(cfg, identity)
|
|
553
553
|
clearNewEager(cfg, identity)
|
|
554
|
-
killSession(sock, identity) // canary-clean deliberate teardown; no-op when dead
|
|
554
|
+
killSession(sock, identity, env) // canary-clean deliberate teardown; no-op when dead
|
|
555
555
|
removeSessionState(cfg, identity) // never a death class — supervise stays silent
|
|
556
556
|
const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
|
|
557
557
|
const r = await wake({ personality, runtime: rt, task: '', resume: false })
|
|
@@ -626,7 +626,7 @@ export async function compactPeer(
|
|
|
626
626
|
if (!done.ok) return { personality, runtime: rt, action: 'failed', woke, reason: done.error.message }
|
|
627
627
|
return null
|
|
628
628
|
}
|
|
629
|
-
const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
|
|
629
|
+
const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir, env))
|
|
630
630
|
if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
|
|
631
631
|
const rt = (runtime as Runtime | undefined)
|
|
632
632
|
?? (liveRuntimes.includes(peer.runtime) ? peer.runtime : liveRuntimes.length === 1 ? liveRuntimes[0] : undefined)
|
|
@@ -744,7 +744,7 @@ export async function removePeerCli(
|
|
|
744
744
|
if (!peer) return { personality, action: 'absent' }
|
|
745
745
|
if (!opts.force) {
|
|
746
746
|
const cfg = loadLifecycleConfig(env)
|
|
747
|
-
const liveRt = peer.runtimes.find(rt => isPeerLive(rt, personality, cfg.sockDir))
|
|
747
|
+
const liveRt = peer.runtimes.find(rt => isPeerLive(rt, personality, cfg.sockDir, env))
|
|
748
748
|
if (liveRt) {
|
|
749
749
|
return {
|
|
750
750
|
personality,
|
|
@@ -969,7 +969,7 @@ export async function renamePeerCli(
|
|
|
969
969
|
}
|
|
970
970
|
const cfg = loadLifecycleConfig(env)
|
|
971
971
|
if (!opts.force) {
|
|
972
|
-
const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir))
|
|
972
|
+
const liveRt = peer.runtimes.find(rt => isPeerLive(rt, oldPersonality, cfg.sockDir, env))
|
|
973
973
|
if (liveRt) {
|
|
974
974
|
return {
|
|
975
975
|
oldPersonality,
|
|
@@ -2325,7 +2325,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2325
2325
|
setNewEager(cfg, identity)
|
|
2326
2326
|
out(`self-fresh: marked ${identity} for eager fresh re-launch; self-killing session\n`)
|
|
2327
2327
|
const sock = buildSocketPath(addr.runtime, addr.personality, cfg.sockDir)
|
|
2328
|
-
killSession(sock, identity)
|
|
2328
|
+
killSession(sock, identity, env)
|
|
2329
2329
|
return 0
|
|
2330
2330
|
}
|
|
2331
2331
|
case 'interrupt': {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
|
|
38
38
|
import { homedir } from 'os'
|
|
39
39
|
import { join } from 'path'
|
|
40
|
-
import {
|
|
41
|
-
import { lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
40
|
+
import { readdirSync, realpathSync, statSync } from 'fs'
|
|
41
|
+
import { codexSessionCwd, lastTimestampedEntryMs } from './transcriptTail.ts'
|
|
42
42
|
import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
|
|
43
43
|
|
|
44
44
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -75,26 +75,8 @@ function codexHooksReviewActive(pane: string): boolean {
|
|
|
75
75
|
// Session activity proxy + resume preflight (watcher / spawner port)
|
|
76
76
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* `session_meta.payload.cwd` (watcher.codexSessionCwd, 200-212). null when the
|
|
81
|
-
* file is missing, unreadable, not a session_meta record, or carries no cwd.
|
|
82
|
-
*/
|
|
83
|
-
function codexSessionCwd(file: string): string | null {
|
|
84
|
-
try {
|
|
85
|
-
const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
|
|
86
|
-
if (!firstLine) return null
|
|
87
|
-
const entry = JSON.parse(firstLine) as {
|
|
88
|
-
type?: unknown
|
|
89
|
-
payload?: { cwd?: unknown }
|
|
90
|
-
}
|
|
91
|
-
return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string'
|
|
92
|
-
? entry.payload.cwd
|
|
93
|
-
: null
|
|
94
|
-
} catch {
|
|
95
|
-
return null
|
|
96
|
-
}
|
|
97
|
-
}
|
|
78
|
+
// codexSessionCwd (bounded first-line read + path-memoized) is shared from transcriptTail.ts —
|
|
79
|
+
// the old full-file readFileSync here read tens of MB per session file on every hot-path scan.
|
|
98
80
|
|
|
99
81
|
/** realpath a path so a symlinked cwd compares equal to the dir codex recorded;
|
|
100
82
|
* a stale/missing path falls through to the original string (the canonicalPath
|
|
@@ -30,6 +30,73 @@ import { openSync, readSync, closeSync, statSync } from 'fs'
|
|
|
30
30
|
* the newline-bounded segments after it. Reaching byte 0 makes every segment complete, including the
|
|
31
31
|
* first — and terminates the growth.
|
|
32
32
|
*/
|
|
33
|
+
/**
|
|
34
|
+
* The FIRST newline-terminated line of a file, read with an ADAPTIVE growing window — NEVER the whole
|
|
35
|
+
* file. A codex `session_meta` first line carries the full instruction payload (measured 29–73 KB on the
|
|
36
|
+
* live host), so a fixed small chunk would miss the terminating `\n` and return a truncated line; the
|
|
37
|
+
* window doubles (capped at file size) until a `\n` is seen or the whole file is read. Returns null when
|
|
38
|
+
* the file is unreadable/empty. Cheap for the common case (one read covers it), bounded otherwise.
|
|
39
|
+
*/
|
|
40
|
+
export function readFirstLine(path: string, chunkBytes = 65536): string | null {
|
|
41
|
+
let fd: number
|
|
42
|
+
try {
|
|
43
|
+
fd = openSync(path, 'r')
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const size = statSync(path).size
|
|
49
|
+
if (size <= 0) return null
|
|
50
|
+
let window = Math.max(chunkBytes, 1)
|
|
51
|
+
for (;;) {
|
|
52
|
+
const len = Math.min(window, size)
|
|
53
|
+
const buf = Buffer.allocUnsafe(len)
|
|
54
|
+
let read = 0
|
|
55
|
+
while (read < len) {
|
|
56
|
+
const n = readSync(fd, buf, read, len - read, read)
|
|
57
|
+
if (n <= 0) break
|
|
58
|
+
read += n
|
|
59
|
+
}
|
|
60
|
+
const text = buf.subarray(0, read).toString('utf8')
|
|
61
|
+
const nl = text.indexOf('\n')
|
|
62
|
+
if (nl >= 0) return text.slice(0, nl).replace(/\r$/, '')
|
|
63
|
+
if (len >= size) return text.replace(/\r$/, '') // whole file, no trailing newline → the single line
|
|
64
|
+
window *= 2
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
} finally {
|
|
69
|
+
closeSync(fd)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Memoized path→cwd. session_meta is written ONCE at session start (immutable) and session-file paths
|
|
74
|
+
* are unique, so caching a DEFINITIVE parse is sound. Only the unreadable/empty case (readFirstLine
|
|
75
|
+
* null) is NOT cached — that can become readable. */
|
|
76
|
+
const codexCwdCache = new Map<string, string | null>()
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The cwd a codex session was opened in — the `session_meta.payload.cwd` of the FIRST jsonl line — read
|
|
80
|
+
* via the bounded readFirstLine and memoized by path. Replaces the old full-file readFileSync that ran
|
|
81
|
+
* per-file on every hot-path scan (superviseTick, ready-gate, delivery baseline). Returns null when the
|
|
82
|
+
* file is missing/unreadable or carries no session_meta cwd.
|
|
83
|
+
*/
|
|
84
|
+
export function codexSessionCwd(file: string): string | null {
|
|
85
|
+
const cached = codexCwdCache.get(file)
|
|
86
|
+
if (cached !== undefined) return cached
|
|
87
|
+
const firstLine = readFirstLine(file)
|
|
88
|
+
if (firstLine === null) return null // unreadable/empty — do NOT cache (may become readable)
|
|
89
|
+
let cwd: string | null = null
|
|
90
|
+
try {
|
|
91
|
+
const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
|
|
92
|
+
if (entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string') cwd = entry.payload.cwd
|
|
93
|
+
} catch {
|
|
94
|
+
cwd = null
|
|
95
|
+
}
|
|
96
|
+
codexCwdCache.set(file, cwd)
|
|
97
|
+
return cwd
|
|
98
|
+
}
|
|
99
|
+
|
|
33
100
|
export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
|
|
34
101
|
let fd: number
|
|
35
102
|
try {
|
package/src/launch/index.ts
CHANGED
|
@@ -183,6 +183,7 @@ export const launch: LaunchFn = async (
|
|
|
183
183
|
paneLogPath,
|
|
184
184
|
exitLogPath: cfg.exitLogDir ? exitLogPath(cfg.exitLogDir) : undefined,
|
|
185
185
|
invocation,
|
|
186
|
+
env, // sandbox the supervisor run-dir (serve-spec + detached spawn) via the injected env
|
|
186
187
|
})
|
|
187
188
|
if (host.ok) {
|
|
188
189
|
// ROUTER (telegram/notifier): no startup dialogs and no transcript. The session is "up" the
|
|
@@ -193,7 +194,7 @@ export const launch: LaunchFn = async (
|
|
|
193
194
|
// router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
|
|
194
195
|
// no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
|
|
195
196
|
if (adapter.kind === 'router') {
|
|
196
|
-
if (hostSessionAlive(identity)) return ready(identity)
|
|
197
|
+
if (hostSessionAlive(identity, env)) return ready(identity)
|
|
197
198
|
return fail(identity, 'hosted router session not alive immediately after bringup')
|
|
198
199
|
}
|
|
199
200
|
// BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
|
|
@@ -201,7 +202,7 @@ export const launch: LaunchFn = async (
|
|
|
201
202
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
|
202
203
|
const baselineMtime = adapter.newestActivityMtime(cwd) ?? 0
|
|
203
204
|
const isReady = await waitHostReady(
|
|
204
|
-
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte },
|
|
205
|
+
{ identity, logDir: cfg.logDir, adapter, bootDeadlineSecs: cfg.bootDeadlineSecs, paneLogStartByte, env },
|
|
205
206
|
sleep,
|
|
206
207
|
)
|
|
207
208
|
if (!isReady) return fail(identity, 'never-became-ready (hosted: supervisor input surface not ready)')
|
|
@@ -220,12 +221,12 @@ export const launch: LaunchFn = async (
|
|
|
220
221
|
},
|
|
221
222
|
{ env },
|
|
222
223
|
)
|
|
223
|
-
const d = await deliverHosted(identity, firstMessage)
|
|
224
|
+
const d = await deliverHosted(identity, firstMessage, env)
|
|
224
225
|
if (!d.ok) return fail(identity, `hosted deliver failed: ${d.error}`)
|
|
225
226
|
const readyDeadline = Date.now() + cfg.readyGateSecs * 1000
|
|
226
227
|
while (Date.now() < readyDeadline) {
|
|
227
228
|
await sleep(2000)
|
|
228
|
-
if (!hostSessionAlive(identity)) return fail(identity, 'hosted session vanished during ready-gate')
|
|
229
|
+
if (!hostSessionAlive(identity, env)) return fail(identity, 'hosted session vanished during ready-gate')
|
|
229
230
|
if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ready(identity)
|
|
230
231
|
}
|
|
231
232
|
return fail(identity, 'model-did-not-process-task (hosted: no activity advance after delivery)')
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -87,7 +87,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
87
87
|
// pty-only liveness: a router is supervisor-HOSTED. Probe by RUNTIME STATE (not the `.pty-host`
|
|
88
88
|
// marker). On child death it goes false → block-watch exits → run-infra exits → launchd KeepAlive
|
|
89
89
|
// respawns (the SOLE respawn owner; the supervisor never self-respawns — H4 held).
|
|
90
|
-
const isAlive = (): boolean => hostSessionAlive(identity)
|
|
90
|
+
const isAlive = (): boolean => hostSessionAlive(identity, env)
|
|
91
91
|
|
|
92
92
|
const cfg: LaunchConfig = {
|
|
93
93
|
claudeBin: env.CLAUDE_BIN ?? 'claude',
|
|
@@ -164,7 +164,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
164
164
|
// kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans up sock/pid/serve)
|
|
165
165
|
// so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
|
|
166
166
|
// (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
|
|
167
|
-
if (stop) killPtyHost(identity)
|
|
167
|
+
if (stop) killPtyHost(identity, env)
|
|
168
168
|
return 0
|
|
169
169
|
}
|
|
170
170
|
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -27,9 +27,11 @@ export const HOST_COLS = 220
|
|
|
27
27
|
export const HOST_ROWS = 50
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
/** The supervisor's run dir (session sockets/pids/serve-specs).
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
/** The supervisor's run dir (session sockets/pids/serve-specs). Resolved from the INJECTED env so a
|
|
31
|
+
* sandboxed caller (cfg.env) never touches the real ~/.iapeer/state/supervisor — the isolation
|
|
32
|
+
* invariant. Defaults to process.env for the prod daemon (env === process.env). */
|
|
33
|
+
export function hostRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
34
|
+
return defaultRunDir(env)
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/**
|
|
@@ -39,13 +41,14 @@ export function hostRunDir(): string {
|
|
|
39
41
|
* means rolling the marker back (rm) on a STILL-RUNNING hosted session never tears it (rm affects only
|
|
40
42
|
* the NEXT spawn). Rollback of a RUNNING hosted session is therefore kill-then-rm, NOT rm-alone.
|
|
41
43
|
*/
|
|
42
|
-
export function hostSessionAlive(identity: string): boolean {
|
|
43
|
-
return hostDaemonAlive(hostRunDir(), identity)
|
|
44
|
+
export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
45
|
+
return hostDaemonAlive(hostRunDir(env), identity)
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up).
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
/** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up).
|
|
49
|
+
* 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(hostRunDir(env), identity)
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
/**
|
|
@@ -56,8 +59,8 @@ export function killPtyHost(identity: string): void {
|
|
|
56
59
|
* a human's unfinished composer input, but NEVER for the AI's own composer output when no one is
|
|
57
60
|
* attached. The host equivalent of `hasAttachedTmuxClient`.
|
|
58
61
|
*/
|
|
59
|
-
export function hasAttachedSupervisorClient(identity: string): boolean {
|
|
60
|
-
return existsSync(attachedPath(hostRunDir(), identity))
|
|
62
|
+
export function hasAttachedSupervisorClient(identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
63
|
+
return existsSync(attachedPath(hostRunDir(env), identity))
|
|
61
64
|
}
|
|
62
65
|
|
|
63
66
|
/** Current child pty geometry of a hosted session, from the supervisor's `<session>.geometry.json`
|
|
@@ -65,8 +68,8 @@ export function hasAttachedSupervisorClient(identity: string): boolean {
|
|
|
65
68
|
* rather than assuming the fixed HOST geometry, because a DETACHED session keeps its last client geometry
|
|
66
69
|
* (the child is no longer reverted to serve on detach — that revert caused the reattach-duplicate bug).
|
|
67
70
|
* Reading the actual geometry keeps the pane-log composer-occupancy model aligned with the real pty size. */
|
|
68
|
-
export function hostGeometry(identity: string): { cols: number; rows: number } {
|
|
69
|
-
return readGeometry(hostRunDir(), identity) ?? { cols: HOST_COLS, rows: HOST_ROWS }
|
|
71
|
+
export function hostGeometry(identity: string, env: NodeJS.ProcessEnv = process.env): { cols: number; rows: number } {
|
|
72
|
+
return readGeometry(hostRunDir(env), identity) ?? { cols: HOST_COLS, rows: HOST_ROWS }
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
/**
|
|
@@ -75,9 +78,9 @@ export function hostGeometry(identity: string): { cols: number; rows: number } {
|
|
|
75
78
|
* session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
|
|
76
79
|
* `session_id:pane_id` token. null when no daemon pid is readable.
|
|
77
80
|
*/
|
|
78
|
-
export function hostSessionToken(identity: string): string | null {
|
|
81
|
+
export function hostSessionToken(identity: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
79
82
|
try {
|
|
80
|
-
const pid = readFileSync(pidPath(hostRunDir(), identity), 'utf8').trim()
|
|
83
|
+
const pid = readFileSync(pidPath(hostRunDir(env), identity), 'utf8').trim()
|
|
81
84
|
return pid ? `host:${pid}` : null
|
|
82
85
|
} catch {
|
|
83
86
|
return null
|
|
@@ -90,9 +93,9 @@ export function hostSessionToken(identity: string): string | null {
|
|
|
90
93
|
* scan (and the no-runtime delivery resolve) include hosted peers. Empty + cheap when flag-off (the
|
|
91
94
|
* run-dir is empty).
|
|
92
95
|
*/
|
|
93
|
-
export function listHostedPeers(): Array<{ runtime: TmuxRuntime; personality: string }> {
|
|
96
|
+
export function listHostedPeers(env: NodeJS.ProcessEnv = process.env): Array<{ runtime: TmuxRuntime; personality: string }> {
|
|
94
97
|
const out: Array<{ runtime: TmuxRuntime; personality: string }> = []
|
|
95
|
-
for (const s of listSupervisorSessions(hostRunDir())) {
|
|
98
|
+
for (const s of listSupervisorSessions(hostRunDir(env))) {
|
|
96
99
|
if (!s.alive) continue
|
|
97
100
|
const p = parseSessionName(s.session)
|
|
98
101
|
if (p) out.push({ runtime: p.runtime, personality: p.personality })
|
|
@@ -118,13 +121,16 @@ export async function startPtyHost(b: {
|
|
|
118
121
|
paneLogPath: string
|
|
119
122
|
exitLogPath?: string
|
|
120
123
|
invocation: LaunchInvocation
|
|
124
|
+
/** Injected env — the run-dir (serve-spec + detached spawn) resolves from THIS, so a sandboxed
|
|
125
|
+
* launch never writes serve secrets to / spawns a process against the real ~/.iapeer. */
|
|
126
|
+
env?: NodeJS.ProcessEnv
|
|
121
127
|
}): Promise<HostStartResult> {
|
|
122
128
|
try {
|
|
123
129
|
const { startSupervisorDaemon } = await import('../supervisor/index.ts')
|
|
124
|
-
const r = startSupervisorDaemon({
|
|
130
|
+
const r = await startSupervisorDaemon({
|
|
125
131
|
session: b.identity,
|
|
126
132
|
runtime: b.runtime,
|
|
127
|
-
runDir: hostRunDir(),
|
|
133
|
+
runDir: hostRunDir(b.env),
|
|
128
134
|
serve: {
|
|
129
135
|
argv: b.invocation.argv,
|
|
130
136
|
env: b.invocation.env as Record<string, string>,
|
|
@@ -148,7 +154,7 @@ export async function startPtyHost(b: {
|
|
|
148
154
|
* 2 s cadence + bootDeadline.
|
|
149
155
|
*/
|
|
150
156
|
export async function waitHostReady(
|
|
151
|
-
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number },
|
|
157
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number; paneLogStartByte?: number; env?: NodeJS.ProcessEnv },
|
|
152
158
|
sleep: (ms: number) => Promise<void>,
|
|
153
159
|
): Promise<boolean> {
|
|
154
160
|
const log = `${b.logDir}/${b.identity}.log`
|
|
@@ -160,7 +166,7 @@ export async function waitHostReady(
|
|
|
160
166
|
// session (the first message is then lost). See readyGateModel.loadPaneLogModel.
|
|
161
167
|
for (let i = 0; i < iters; i++) {
|
|
162
168
|
await sleep(2000)
|
|
163
|
-
if (!hostSessionAlive(b.identity)) return false
|
|
169
|
+
if (!hostSessionAlive(b.identity, b.env)) return false
|
|
164
170
|
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS, b.paneLogStartByte ?? 0)
|
|
165
171
|
if (view && b.adapter.isInputReady(view)) return true
|
|
166
172
|
}
|
|
@@ -168,6 +174,6 @@ export async function waitHostReady(
|
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
/** Deliver the first/next message to the hosted session over its supervisor socket. */
|
|
171
|
-
export function deliverHosted(identity: string, message: string): Promise<DeliverResult> {
|
|
172
|
-
return deliverToHost(hostRunDir(), identity, message)
|
|
177
|
+
export function deliverHosted(identity: string, message: string, env: NodeJS.ProcessEnv = process.env): Promise<DeliverResult> {
|
|
178
|
+
return deliverToHost(hostRunDir(env), identity, message)
|
|
173
179
|
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -715,11 +715,12 @@ export async function withWakeLock<T>(
|
|
|
715
715
|
// liveness
|
|
716
716
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
717
717
|
|
|
718
|
-
function sessionAlive(_sock: string, identity: string): boolean {
|
|
718
|
+
function sessionAlive(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
719
719
|
// pty-only: a peer is alive iff its supervisor session is live (a cheap pid-file check). Routing
|
|
720
720
|
// liveness on the live session (not the `.pty-host` spawn-intent marker) keeps a marker rollback safe
|
|
721
721
|
// on a still-running session. (`_sock` retained for call-site compatibility; no tmux sockets exist.)
|
|
722
|
-
|
|
722
|
+
// env sandboxes the run-dir — a test with an injected root must not read the real fleet's liveness.
|
|
723
|
+
return hostSessionAlive(identity, env)
|
|
723
724
|
}
|
|
724
725
|
|
|
725
726
|
/** Death-class tag for a gone session (pty-only). A gone peer is a supervisor session that ended; its
|
|
@@ -969,7 +970,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
969
970
|
}
|
|
970
971
|
// Idempotent fast path inside the lock: a live session wins (a concurrent
|
|
971
972
|
// wake already brought it up) — no second spawn.
|
|
972
|
-
if (sessionAlive(sock, identity)) {
|
|
973
|
+
if (sessionAlive(sock, identity, env)) {
|
|
973
974
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
974
975
|
// taskDelivered:false — the winning concurrent wake delivered ITS OWN task as
|
|
975
976
|
// the boot first-message; THIS caller's task was not delivered by anything.
|
|
@@ -1069,7 +1070,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1069
1070
|
// path above). We do NOT flip to READY — the wake DID fail its ready-gate, the caller
|
|
1070
1071
|
// must still hear FAILED; we only make the live remnant supervised. Best-effort: a write
|
|
1071
1072
|
// hiccup must not mask the original FAILED reason.
|
|
1072
|
-
if (sessionAlive(sock, identity)) {
|
|
1073
|
+
if (sessionAlive(sock, identity, env)) {
|
|
1073
1074
|
try {
|
|
1074
1075
|
writeSessionState(cfg, { identity, runtime, personality: args.personality, cwd, wokeAt: Date.now() })
|
|
1075
1076
|
} catch { /* keep the original FAILED reason — enroll is best-effort */ }
|
|
@@ -1108,11 +1109,12 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1108
1109
|
// Reap — kill a session (used by idle-reap / supervise; H4-guarded by callers)
|
|
1109
1110
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1110
1111
|
|
|
1111
|
-
export function killSession(_sock: string, identity: string): void {
|
|
1112
|
+
export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
|
|
1112
1113
|
// pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
|
|
1113
1114
|
// sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
|
|
1114
|
-
// call-site compatibility; no tmux sockets exist.)
|
|
1115
|
-
|
|
1115
|
+
// call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
|
|
1116
|
+
// injected root must NEVER SIGTERM a real fleet peer's supervisor.
|
|
1117
|
+
killPtyHost(identity, env)
|
|
1116
1118
|
}
|
|
1117
1119
|
|
|
1118
1120
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1150,6 +1152,9 @@ export interface SuperviseDeps {
|
|
|
1150
1152
|
/** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
|
|
1151
1153
|
* verdict so the idle path is exercised hermetically — no live pty session needed. */
|
|
1152
1154
|
sessionAlive?: (sock: string, identity: string) => boolean
|
|
1155
|
+
/** Reap seam (default: the real `killSession` = SIGTERM the supervisor daemon). Tests inject a no-op
|
|
1156
|
+
* so exercising the reap path NEVER SIGTERMs a real process — the destructive-kill isolation guard. */
|
|
1157
|
+
killSession?: (sock: string, identity: string) => void
|
|
1153
1158
|
}
|
|
1154
1159
|
|
|
1155
1160
|
/** Resolved per-tick context threaded into superviseOnePeer (deps already defaulted by superviseTick). */
|
|
@@ -1159,6 +1164,7 @@ interface SuperviseCtx {
|
|
|
1159
1164
|
nowMs: number
|
|
1160
1165
|
lastTurnMt: (runtime: string, cwd: string) => number | null
|
|
1161
1166
|
isAlive: (sock: string, identity: string) => boolean
|
|
1167
|
+
kill: (sock: string, identity: string) => void
|
|
1162
1168
|
verbose: boolean
|
|
1163
1169
|
trace: (fields: Record<string, string | number | undefined>) => void
|
|
1164
1170
|
}
|
|
@@ -1171,7 +1177,7 @@ interface SuperviseCtx {
|
|
|
1171
1177
|
* returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
|
|
1172
1178
|
*/
|
|
1173
1179
|
function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
|
|
1174
|
-
const { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace } = ctx
|
|
1180
|
+
const { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace } = ctx
|
|
1175
1181
|
// H4 — FIRST, before any reap. A launchd-managed peer is read-only.
|
|
1176
1182
|
if (isLaunchdManaged(s.personality, env)) {
|
|
1177
1183
|
if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
|
|
@@ -1252,7 +1258,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1252
1258
|
// Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
|
|
1253
1259
|
// and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
|
|
1254
1260
|
if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
|
|
1255
|
-
|
|
1261
|
+
kill(sock, s.identity)
|
|
1256
1262
|
clearEphemeralArmed(cfg, s.identity)
|
|
1257
1263
|
removeSessionState(cfg, s.identity)
|
|
1258
1264
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-done' })
|
|
@@ -1278,7 +1284,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1278
1284
|
// tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
|
|
1279
1285
|
// reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
|
|
1280
1286
|
if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
|
|
1281
|
-
|
|
1287
|
+
kill(sock, s.identity)
|
|
1282
1288
|
clearEphemeralArmed(cfg, s.identity)
|
|
1283
1289
|
removeSessionState(cfg, s.identity)
|
|
1284
1290
|
trace({ identity: s.identity, action: 'reaped-ephemeral', age: `${ageSecs}s`, outcome: 'ephemeral-unarmed-bound' })
|
|
@@ -1295,7 +1301,7 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1295
1301
|
// INITIATES. Its presence on the next wake = the session was parked cleanly =
|
|
1296
1302
|
// RESUME-eligible (resolveWakeMode branch 3b). A crash/self-close (the dead
|
|
1297
1303
|
// branch above) never writes it → that wakes FRESH (branch 3a).
|
|
1298
|
-
|
|
1304
|
+
kill(sock, s.identity)
|
|
1299
1305
|
setIdleReaped(cfg, s.identity)
|
|
1300
1306
|
removeSessionState(cfg, s.identity)
|
|
1301
1307
|
trace({ identity: s.identity, action: 'reaped-idle', age: `${ageSecs}s`, outcome: 'resume-eligible' })
|
|
@@ -1311,7 +1317,9 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1311
1317
|
const env = deps.env ?? process.env
|
|
1312
1318
|
const nowMs = deps.nowMs ?? Date.now()
|
|
1313
1319
|
const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
|
|
1314
|
-
|
|
1320
|
+
// Default seams bind the injected env so a sandboxed tick never reads/kills the real fleet.
|
|
1321
|
+
const isAlive = deps.sessionAlive ?? ((sock: string, identity: string) => sessionAlive(sock, identity, env))
|
|
1322
|
+
const kill = deps.killSession ?? ((sock: string, identity: string) => killSession(sock, identity, env))
|
|
1315
1323
|
const verbose = superviseLogVerbose(env)
|
|
1316
1324
|
// Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
|
|
1317
1325
|
// (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
|
|
@@ -1328,7 +1336,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1328
1336
|
const trace = (fields: Record<string, string | number | undefined>): void =>
|
|
1329
1337
|
appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
|
|
1330
1338
|
const out: SuperviseOutcome[] = []
|
|
1331
|
-
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace }
|
|
1339
|
+
const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, kill, verbose, trace }
|
|
1332
1340
|
for (const s of readSessionStates(cfg)) {
|
|
1333
1341
|
// Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
|
|
1334
1342
|
// the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the
|
|
@@ -1434,7 +1442,7 @@ export async function drainEphemeralQueue(
|
|
|
1434
1442
|
const env = deps.env ?? process.env
|
|
1435
1443
|
const identity = buildProcessAddress(runtime, personality)
|
|
1436
1444
|
const sock = buildSocketPath(runtime, personality, cfg.sockDir)
|
|
1437
|
-
if (sessionAlive(sock, identity)) return null // one task per session — wait for its reap
|
|
1445
|
+
if (sessionAlive(sock, identity, env)) return null // one task per session — wait for its reap
|
|
1438
1446
|
const item = peekEphemeralTask(cfg, identity)
|
|
1439
1447
|
if (!item) return null
|
|
1440
1448
|
// Durable drain trace: which item, how deep the queue.
|
|
@@ -1445,7 +1453,14 @@ export async function drainEphemeralQueue(
|
|
|
1445
1453
|
)
|
|
1446
1454
|
const wake = deps.wakeFn ?? wakeOrSpawn
|
|
1447
1455
|
const result = await wake({ personality, runtime, topic: item.topic, task: item.task }, { cfg, env })
|
|
1448
|
-
|
|
1456
|
+
// Remove ONLY when THIS wake actually delivered the task. On the live-session fast
|
|
1457
|
+
// path (READY, taskDelivered:false) a CONCURRENT wake delivered ITS OWN task — ours
|
|
1458
|
+
// was NOT delivered. Deleting it here would silently drop a queued task (the loss the
|
|
1459
|
+
// contract forbids); leaving it at the head lets the next drain (after that session
|
|
1460
|
+
// reaps) deliver it into a fresh session — invariant "one task = one fresh session".
|
|
1461
|
+
if (result.status === 'READY' && result.taskDelivered !== false) {
|
|
1462
|
+
removeEphemeralTask(cfg, identity, item.seq)
|
|
1463
|
+
}
|
|
1449
1464
|
return result
|
|
1450
1465
|
}
|
|
1451
1466
|
|
|
@@ -1533,9 +1548,9 @@ export function lastActiveRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runti
|
|
|
1533
1548
|
|
|
1534
1549
|
/** Runtimes of `peer` whose session is LIVE right now (lifecycle-local liveness,
|
|
1535
1550
|
* same `sessionAlive` predicate the wake/attach paths use). */
|
|
1536
|
-
function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig): Runtime[] {
|
|
1551
|
+
function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig, env: NodeJS.ProcessEnv = process.env): Runtime[] {
|
|
1537
1552
|
return peer.runtimes.filter(rt =>
|
|
1538
|
-
sessionAlive(buildSocketPath(rt, peer.personality, cfg.sockDir), buildProcessAddress(rt, peer.personality)),
|
|
1553
|
+
sessionAlive(buildSocketPath(rt, peer.personality, cfg.sockDir), buildProcessAddress(rt, peer.personality), env),
|
|
1539
1554
|
)
|
|
1540
1555
|
}
|
|
1541
1556
|
|
|
@@ -1563,9 +1578,9 @@ function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig): Runtime[] {
|
|
|
1563
1578
|
* arg) resolve to codex, set the peer's default to codex once; per-command runtime
|
|
1564
1579
|
* args remain the explicit override.
|
|
1565
1580
|
*/
|
|
1566
|
-
export function resolvePeerRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runtime {
|
|
1581
|
+
export function resolvePeerRuntime(peer: PeerRecord, cfg: LifecycleConfig, env: NodeJS.ProcessEnv = process.env): Runtime {
|
|
1567
1582
|
if (peer.runtimes.length === 1) return peer.runtimes[0]
|
|
1568
|
-
const live = liveRuntimesOf(peer, cfg)
|
|
1583
|
+
const live = liveRuntimesOf(peer, cfg, env)
|
|
1569
1584
|
if (live.includes(peer.runtime)) return peer.runtime
|
|
1570
1585
|
if (live.length === 1) return live[0]
|
|
1571
1586
|
return peer.runtime
|
|
@@ -1603,7 +1618,7 @@ export async function attachPeer(opts: AttachOptions): Promise<AttachResult> {
|
|
|
1603
1618
|
const runtime = opts.runtime ?? resolvePeerRuntime(peer, cfg)
|
|
1604
1619
|
const identity = buildProcessAddress(runtime, opts.personality)
|
|
1605
1620
|
const sock = buildSocketPath(runtime, opts.personality, cfg.sockDir)
|
|
1606
|
-
if (sessionAlive(sock, identity)) return { ok: true, identity, socketPath: sock, woke: false, runtime }
|
|
1621
|
+
if (sessionAlive(sock, identity, env)) return { ok: true, identity, socketPath: sock, woke: false, runtime }
|
|
1607
1622
|
const woke = await wakeOrSpawn({ personality: opts.personality, runtime, task: '', resume: true }, { cfg, env })
|
|
1608
1623
|
if (woke.status === 'FAILED') return { ok: false, reason: woke.reason ?? 'wake failed' }
|
|
1609
1624
|
return { ok: true, identity, socketPath: sock, woke: true, runtime }
|
package/src/lifecycle/queue.ts
CHANGED
|
@@ -43,6 +43,30 @@ export function ephemeralQueueDir(cfg: LifecycleConfig, identity: string): strin
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
const SEQ_PAD = 6
|
|
46
|
+
const HWM_FILE = '.hwm'
|
|
47
|
+
|
|
48
|
+
/** High-water mark of seqs ever ISSUED for this queue — the monotonic floor that
|
|
49
|
+
* makes names never repeat, even after the queue drains empty (`max(existing)+1`
|
|
50
|
+
* alone resets to 1 once empty, so a concurrent drain still holding the OLD seq
|
|
51
|
+
* could delete a fresh task that got the reused name). 0 when absent/unreadable. */
|
|
52
|
+
function readHwm(dir: string): number {
|
|
53
|
+
try {
|
|
54
|
+
const n = parseInt(readFileSync(join(dir, HWM_FILE), 'utf8'), 10)
|
|
55
|
+
return Number.isFinite(n) && n >= 0 ? n : 0
|
|
56
|
+
} catch {
|
|
57
|
+
return 0
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Raise the high-water mark (monotonic; only ever rises). Best-effort — a lost
|
|
62
|
+
* bump merely risks a reuse after a FULL drain, the same rare window we harden. */
|
|
63
|
+
function bumpHwm(dir: string, seq: number): void {
|
|
64
|
+
try {
|
|
65
|
+
if (seq > readHwm(dir)) writeFileSync(join(dir, HWM_FILE), String(seq), { mode: 0o600 })
|
|
66
|
+
} catch {
|
|
67
|
+
/* best-effort */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
46
70
|
|
|
47
71
|
function listSeqs(dir: string): string[] {
|
|
48
72
|
let entries: string[]
|
|
@@ -79,16 +103,19 @@ export function enqueueEphemeralTask(
|
|
|
79
103
|
mode: 0o600,
|
|
80
104
|
})
|
|
81
105
|
try {
|
|
82
|
-
// Next seq = max
|
|
106
|
+
// Next seq = max(existing items, ever-issued high-water) + 1 — MONOTONIC: never
|
|
107
|
+
// reuse a name after the queue empties. On an EEXIST race, advance and retry.
|
|
83
108
|
let seq = (() => {
|
|
84
109
|
const seqs = listSeqs(dir)
|
|
85
|
-
|
|
110
|
+
const maxExisting = seqs.length ? parseInt(seqs[seqs.length - 1]!, 10) : 0
|
|
111
|
+
return Math.max(maxExisting, readHwm(dir)) + 1
|
|
86
112
|
})()
|
|
87
113
|
// Bounded retry: a competitor can win a name at most once per its own enqueue.
|
|
88
114
|
for (let attempt = 0; attempt < 1000; attempt++, seq++) {
|
|
89
115
|
const target = join(dir, String(seq).padStart(SEQ_PAD, '0'))
|
|
90
116
|
try {
|
|
91
117
|
linkSync(tmp, target) // atomic exclusive-create (EEXIST when taken)
|
|
118
|
+
bumpHwm(dir, seq) // record the issued name so it is never reused
|
|
92
119
|
return listSeqs(dir).length
|
|
93
120
|
} catch (e) {
|
|
94
121
|
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e
|
package/src/storage/rotatelog.ts
CHANGED
|
Binary file
|
package/src/supervisor/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 { existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
9
9
|
import { fileURLToPath } from 'node:url'
|
|
10
10
|
import { runSupervisorClient, sessionAlive } from './client.ts'
|
|
11
11
|
import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
|
|
@@ -86,8 +86,10 @@ export function resolveDaemonSelfArgv(execPath: string, selfPath: string): strin
|
|
|
86
86
|
}
|
|
87
87
|
const selfArgv = (): string[] => resolveDaemonSelfArgv(process.execPath, fileURLToPath(import.meta.url))
|
|
88
88
|
|
|
89
|
-
/** Start a detached supervisor daemon and wait until its socket is live. Idempotent.
|
|
90
|
-
|
|
89
|
+
/** Start a detached supervisor daemon and wait until its socket is live. Idempotent.
|
|
90
|
+
* Async: the socket-wait yields the event loop (a sync busy-wait here froze the whole
|
|
91
|
+
* router daemon for the bring-up window on every warm wake). */
|
|
92
|
+
export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<StartDaemonResult> {
|
|
91
93
|
const { session, runtime, runDir } = opts
|
|
92
94
|
if (sessionAlive(runDir, session)) return { state: 'already-running' }
|
|
93
95
|
mkdirSync(runDir, { recursive: true })
|
|
@@ -111,13 +113,21 @@ export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResu
|
|
|
111
113
|
detached: true,
|
|
112
114
|
stdio: ['ignore', out, out],
|
|
113
115
|
})
|
|
116
|
+
// The child dup'd `out` at spawn — close the PARENT copy immediately. This function
|
|
117
|
+
// runs inside the always-on router daemon on EVERY warm wake; an unclosed fd here bled
|
|
118
|
+
// the daemon ~1 fd/wake toward EMFILE (live-observed) → eventual full routing outage.
|
|
119
|
+
try {
|
|
120
|
+
closeSync(out)
|
|
121
|
+
} catch {
|
|
122
|
+
/* */
|
|
123
|
+
}
|
|
114
124
|
child.unref()
|
|
115
125
|
const deadline = Date.now() + (opts.timeoutMs ?? 5000)
|
|
116
126
|
while (Date.now() < deadline) {
|
|
117
127
|
if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
|
|
118
128
|
return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
|
|
119
129
|
}
|
|
120
|
-
Bun.
|
|
130
|
+
await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
|
|
121
131
|
}
|
|
122
132
|
// Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
|
|
123
133
|
if (opts.serve)
|
|
@@ -192,7 +202,7 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
|
|
|
192
202
|
return 0 // unreachable
|
|
193
203
|
}
|
|
194
204
|
case 'start': {
|
|
195
|
-
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
205
|
+
const r = await startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
196
206
|
console.log(`${r.state}${r.pid ? ` pid=${r.pid}` : ''}${r.detail ? ` — ${r.detail}` : ''}: ${a}`)
|
|
197
207
|
return r.state === 'failed' ? 1 : 0
|
|
198
208
|
}
|
|
@@ -200,7 +210,7 @@ export async function runSupervisorCli(argv: string[]): Promise<number> {
|
|
|
200
210
|
await runSupervisorClient(runDir, a!)
|
|
201
211
|
return 0
|
|
202
212
|
case 'up': {
|
|
203
|
-
const r = startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
213
|
+
const r = await startSupervisorDaemon({ session: a!, runtime: b || 'claude', runDir })
|
|
204
214
|
if (r.state === 'failed') {
|
|
205
215
|
console.error(r.detail)
|
|
206
216
|
return 1
|
package/src/transport/index.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// runtime's adapter.deliveryMarkers. The poll/re-press logic is untouched; only the
|
|
14
14
|
// marker SOURCE moved, so the timing behaviour is identical.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { closeSync, fstatSync, openSync, readSync, readdirSync, realpathSync, statSync } from 'fs'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { homedir } from 'os'
|
|
19
19
|
import {
|
|
@@ -40,6 +40,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
|
|
|
40
40
|
// reads them from getAdapter(target.runtime) for the tui submit path. One-way
|
|
41
41
|
// dependency: launch does NOT import transport, so no cycle.
|
|
42
42
|
import { getAdapter } from '../launch/index.ts'
|
|
43
|
+
import { codexSessionCwd } from '../launch/adapters/transcriptTail.ts'
|
|
43
44
|
import type { ControlCommand } from '../launch/types.ts'
|
|
44
45
|
// Spawn-flip Ф0b-2: warm-deliver is HOST-AWARE. hostSessionAlive (runtime-state host detect, an
|
|
45
46
|
// @xterm-free pid-file check) + deliverHosted (the pure-protocol socket leaf, Ф0a) come from
|
|
@@ -78,22 +79,23 @@ function monotonicMs(): number {
|
|
|
78
79
|
return Number(process.hrtime.bigint() / 1_000_000n)
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
function sessionAlive(_sock: string, address: string): boolean {
|
|
82
|
+
function sessionAlive(_sock: string, address: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
82
83
|
// pty-only: a peer's liveness is its supervisor daemon (a cheap pid-file check). `address` IS the
|
|
83
84
|
// identity. (`_sock` is retained for call-site compatibility; tmux sockets no longer exist.)
|
|
84
|
-
|
|
85
|
+
// env sandboxes the run-dir — an env-injected verb must not read the real fleet's liveness.
|
|
86
|
+
return hostSessionAlive(address, env)
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
|
|
88
|
-
export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
|
|
90
|
+
export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): boolean {
|
|
89
91
|
const socketPath = buildSocketPath(runtime, personality, sockDir)
|
|
90
|
-
return sessionAlive(socketPath, buildProcessAddress(runtime, personality))
|
|
92
|
+
return sessionAlive(socketPath, buildProcessAddress(runtime, personality), env)
|
|
91
93
|
}
|
|
92
94
|
|
|
93
|
-
export function listOnlinePeers(_sockDir = resolveSockDir()): OnlinePeer[] {
|
|
95
|
+
export function listOnlinePeers(_sockDir = resolveSockDir(), env: NodeJS.ProcessEnv = process.env): OnlinePeer[] {
|
|
94
96
|
// pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
|
|
95
97
|
const out = new Map<string, OnlinePeer>()
|
|
96
|
-
for (const h of listHostedPeers()) {
|
|
98
|
+
for (const h of listHostedPeers(env)) {
|
|
97
99
|
out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
|
|
98
100
|
}
|
|
99
101
|
return Array.from(out.values()).sort(
|
|
@@ -234,7 +236,11 @@ async function deliverViaHost(
|
|
|
234
236
|
// Baseline the transcript/session-jsonl file SIZES BEFORE delivery (per-runtime candidate files).
|
|
235
237
|
// The landed-confirm reads ONLY the bytes appended past this baseline, so a record carrying our
|
|
236
238
|
// envelope can only be one written in RESPONSE to this delivery — never a pre-existing copy.
|
|
237
|
-
|
|
239
|
+
// LAZY: only 'transcript'-confirm runtimes (claude) use the baseline; router (socket-ack) and
|
|
240
|
+
// socket-ack runtimes (codex) return before it — computing it for them recursively scanned all of
|
|
241
|
+
// ~/.codex/sessions (full-file read per session) on every delivery for nothing.
|
|
242
|
+
const usesBaseline = adapter.kind !== 'router' && adapter.deliveryConfirm !== 'socket-ack'
|
|
243
|
+
const baseline = cwd && usesBaseline ? compactDoneBaseline(target.runtime, cwd) : null
|
|
238
244
|
const send = seam.deliverHosted ?? deliverHosted
|
|
239
245
|
const sent = await send(target.address, envelope)
|
|
240
246
|
if (!sent.ok) {
|
|
@@ -374,14 +380,36 @@ function claudeTranscriptDirForCwd(cwd: string, env?: NodeJS.ProcessEnv): string
|
|
|
374
380
|
return join(envHome(env), '.claude', 'projects', slug)
|
|
375
381
|
}
|
|
376
382
|
|
|
377
|
-
|
|
383
|
+
// codexSessionCwd (bounded first-line read + path-memoized) is shared from launch/adapters/transcriptTail.ts —
|
|
384
|
+
// the old full-file readFileSync here scanned tens of MB per session file on every warm delivery baseline.
|
|
385
|
+
|
|
386
|
+
/** Read ONLY the bytes of `path` past `offset` (the appended tail), without loading the whole file.
|
|
387
|
+
* O(added bytes) instead of O(entire history) — the transcript files this scans grow to hundreds of MB.
|
|
388
|
+
* Returns '' on any fs error / empty tail. */
|
|
389
|
+
function readFileTailFrom(path: string, offset: number): string {
|
|
390
|
+
let fd: number
|
|
378
391
|
try {
|
|
379
|
-
|
|
380
|
-
if (!firstLine) return null
|
|
381
|
-
const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
|
|
382
|
-
return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string' ? entry.payload.cwd : null
|
|
392
|
+
fd = openSync(path, 'r')
|
|
383
393
|
} catch {
|
|
384
|
-
return
|
|
394
|
+
return ''
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const size = fstatSync(fd).size
|
|
398
|
+
const start = Math.min(Math.max(0, offset), size)
|
|
399
|
+
const len = size - start
|
|
400
|
+
if (len <= 0) return ''
|
|
401
|
+
const buf = Buffer.allocUnsafe(len)
|
|
402
|
+
let read = 0
|
|
403
|
+
while (read < len) {
|
|
404
|
+
const n = readSync(fd, buf, read, len - read, start + read)
|
|
405
|
+
if (n <= 0) break
|
|
406
|
+
read += n
|
|
407
|
+
}
|
|
408
|
+
return buf.subarray(0, read).toString('utf8')
|
|
409
|
+
} catch {
|
|
410
|
+
return ''
|
|
411
|
+
} finally {
|
|
412
|
+
closeSync(fd)
|
|
385
413
|
}
|
|
386
414
|
}
|
|
387
415
|
|
|
@@ -478,14 +506,8 @@ export function compactTranscriptHasDone(
|
|
|
478
506
|
}
|
|
479
507
|
|
|
480
508
|
for (const [path, offset] of offsets) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
buf = readFileSync(path)
|
|
484
|
-
} catch {
|
|
485
|
-
continue
|
|
486
|
-
}
|
|
487
|
-
const start = Math.min(Math.max(0, offset), buf.length)
|
|
488
|
-
const tail = buf.subarray(start).toString('utf8')
|
|
509
|
+
const tail = readFileTailFrom(path, offset) // tail-only read (see transcriptCarriesEnvelope)
|
|
510
|
+
if (!tail) continue
|
|
489
511
|
for (const line of tail.split(/\r?\n/)) {
|
|
490
512
|
if (!line.trim()) continue
|
|
491
513
|
let obj: unknown
|
|
@@ -537,14 +559,11 @@ export function transcriptCarriesEnvelope(baseline: CompactDoneBaseline, envelop
|
|
|
537
559
|
const needle = envelope.replace(/\r/g, '')
|
|
538
560
|
if (!needle) return false
|
|
539
561
|
for (const { path, size: offset } of baseline.files) {
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
const start = Math.min(Math.max(0, offset), buf.length)
|
|
547
|
-
const tail = buf.subarray(start).toString('utf8')
|
|
562
|
+
// Read ONLY the tail past the baseline offset — a full readFileSync here re-read the ENTIRE
|
|
563
|
+
// transcript (hundreds of MB for a long-running peer) on every ~100ms confirm-poll, stalling the
|
|
564
|
+
// single-threaded router event loop for the whole fleet.
|
|
565
|
+
const tail = readFileTailFrom(path, offset)
|
|
566
|
+
if (!tail) continue
|
|
548
567
|
for (const line of tail.split(/\r?\n/)) {
|
|
549
568
|
if (!line.trim()) continue
|
|
550
569
|
let obj: unknown
|