@agfpd/iapeer 0.2.67 → 0.2.69
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
CHANGED
package/src/launch/ptyHost.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
14
14
|
import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
15
|
import { attachedPath, defaultRunDir, pidPath } from '../supervisor/paths.ts'
|
|
16
16
|
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
|
-
import { killSession as killSupervisorSession } from '../supervisor/index.ts'
|
|
17
|
+
import { killSession as killSupervisorSession, listSessions as listSupervisorSessions } from '../supervisor/index.ts'
|
|
18
|
+
import { parseSessionName } from '../core/socket.ts'
|
|
19
|
+
import type { TmuxRuntime } from '../core/constants.ts'
|
|
18
20
|
import { paneLogViewport } from './readyGateModel.ts'
|
|
19
21
|
import type { LaunchInvocation } from './invocation.ts'
|
|
20
22
|
import type { RuntimeAdapter } from './types.ts'
|
|
@@ -80,6 +82,22 @@ export function hostSessionToken(identity: string): string | null {
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Online supervisor-HOSTED peers (spawn-flip Ф0b-3): live sessions in the supervisor run-dir. A hosted
|
|
87
|
+
* peer has NO tmux socket, so the tmux-based listOnlinePeers scan can't see it — this lets the online
|
|
88
|
+
* scan (and the no-runtime delivery resolve) include hosted peers. Empty + cheap when flag-off (the
|
|
89
|
+
* run-dir is empty).
|
|
90
|
+
*/
|
|
91
|
+
export function listHostedPeers(): Array<{ runtime: TmuxRuntime; personality: string }> {
|
|
92
|
+
const out: Array<{ runtime: TmuxRuntime; personality: string }> = []
|
|
93
|
+
for (const s of listSupervisorSessions(hostRunDir())) {
|
|
94
|
+
if (!s.alive) continue
|
|
95
|
+
const p = parseSessionName(s.session)
|
|
96
|
+
if (p) out.push({ runtime: p.runtime, personality: p.personality })
|
|
97
|
+
}
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
export interface HostStartResult {
|
|
84
102
|
ok: boolean
|
|
85
103
|
error?: string
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -1255,9 +1255,16 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
|
|
|
1255
1255
|
// alive server means the previous canary VANISHED mid-watch (the death-#4
|
|
1256
1256
|
// blind spot was exactly this churn being invisible); 'failed' means the
|
|
1257
1257
|
// server is currently UNWATCHED. At most one line per loss, not per tick.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1258
|
+
// SPAWN-FLIP Ф0b-3: the tmux server-death canary is meaningless for a supervisor-HOSTED session
|
|
1259
|
+
// (no tmux server — the retrofit arms a watcher that immediately fires a spurious
|
|
1260
|
+
// `server-vanished`). A hosted death is recorded by the supervisor's exits.log (host=supervisor,
|
|
1261
|
+
// resilience-b), so skip the canary retrofit for hosted peers (mirror of the observer .pty-host
|
|
1262
|
+
// skip — tmux-assuming infra must not run against a hosted session).
|
|
1263
|
+
if (!hostSessionAlive(s.identity)) {
|
|
1264
|
+
const canaryState = ensureServerCanary({ identity: s.identity, sock, exitLogDir: cfg.eventLogDir, env })
|
|
1265
|
+
if (canaryState !== 'already') {
|
|
1266
|
+
trace({ identity: s.identity, action: 'canary', state: canaryState, origin: 'retrofit' })
|
|
1267
|
+
}
|
|
1261
1268
|
}
|
|
1262
1269
|
out.push({ identity: s.identity, action: 'alive' })
|
|
1263
1270
|
if (verbose) trace({ identity: s.identity, action: 'alive', age: `${ageSecs}s` })
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { killSession, sessionAlive, startSupervisorDaemon } from './index.ts'
|
|
6
|
+
|
|
7
|
+
// SURFACE-5 regression — the interactive attach-and-stay loop. The other supervisor tests attach a
|
|
8
|
+
// RAW socket; none drives the real `runSupervisorClient` under a TTY, so the canary's first live
|
|
9
|
+
// `iapeer attach` exposed a bug invisible to them: runSupervisorClient resolved its Promise the
|
|
10
|
+
// instant Bun.connect returned (setup done), the CLI caller ran `await runSupervisorClient(); return
|
|
11
|
+
// 0` → runCli's `.then(process.exit)`, and the just-attached client was torn down within
|
|
12
|
+
// milliseconds (handshake-then-exit). The fix makes the client BLOCK (never-resolve) after setup so
|
|
13
|
+
// process.exit from a terminal path owns termination — exactly the detached `daemon` case's guard.
|
|
14
|
+
//
|
|
15
|
+
// This is the headless verifier of that fix AND the missing surface-5 automation: allocate a REAL
|
|
16
|
+
// Bun-native pty, run the actual client CLI in it, assert it STAYS attached + renders live frames +
|
|
17
|
+
// Ctrl-] detaches cleanly while the session keeps running. Arthur's live attach is the final human
|
|
18
|
+
// seal; this test makes every iteration of the fix verifiable WITHOUT pulling him in.
|
|
19
|
+
// Gated on Bun-native PTY (≥1.3.5) so a runner without it skips rather than reds.
|
|
20
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
21
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
22
|
+
const d = bunPty ? describe : describe.skip
|
|
23
|
+
|
|
24
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
25
|
+
async function waitFor(pred: () => boolean, timeoutMs = 4000, step = 50): Promise<boolean> {
|
|
26
|
+
const end = Date.now() + timeoutMs
|
|
27
|
+
while (Date.now() < end) {
|
|
28
|
+
if (pred()) return true
|
|
29
|
+
await sleep(step)
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Spawn the real attach client under a real pty (its stdin/stdout is a TTY → raw-mode path runs).
|
|
35
|
+
interface PtyClient {
|
|
36
|
+
out(): string
|
|
37
|
+
exited(): boolean
|
|
38
|
+
sendKey(byte: number): void
|
|
39
|
+
}
|
|
40
|
+
function spawnAttachClient(runDir: string, session: string): PtyClient {
|
|
41
|
+
let buf = ''
|
|
42
|
+
let done = false
|
|
43
|
+
const proc = Bun.spawn(['bun', 'src/supervisor/index.ts', 'attach', session, '--run-dir', runDir], {
|
|
44
|
+
cwd: process.cwd(),
|
|
45
|
+
env: { ...process.env },
|
|
46
|
+
// a real Bun-native pty pair: the client sees a TTY on stdin (isTTY → setRawMode runs).
|
|
47
|
+
terminal: {
|
|
48
|
+
cols: 80,
|
|
49
|
+
rows: 24,
|
|
50
|
+
name: 'xterm-256color',
|
|
51
|
+
data(_t: unknown, data: Uint8Array | string) {
|
|
52
|
+
buf += Buffer.from(data as Uint8Array).toString('latin1')
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Bun pty spawn opts not yet in @types
|
|
56
|
+
} as any)
|
|
57
|
+
proc.exited.then(() => {
|
|
58
|
+
done = true
|
|
59
|
+
})
|
|
60
|
+
return {
|
|
61
|
+
out: () => buf,
|
|
62
|
+
exited: () => done,
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- proc.terminal is the pty master writer
|
|
64
|
+
sendKey: (byte: number) => void (proc as any).terminal.write(Buffer.from([byte])),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
d('runSupervisorClient interactive attach (surface-5, Bun-native pty)', () => {
|
|
69
|
+
let runDir: string
|
|
70
|
+
const session = 'ptsattach'
|
|
71
|
+
|
|
72
|
+
beforeAll(() => {
|
|
73
|
+
runDir = mkdtempSync(join(tmpdir(), 'iapeer-attach-'))
|
|
74
|
+
startSupervisorDaemon({ session, runtime: 'tick', runDir })
|
|
75
|
+
})
|
|
76
|
+
afterAll(() => {
|
|
77
|
+
try {
|
|
78
|
+
killSession(runDir, session)
|
|
79
|
+
} catch {
|
|
80
|
+
/* */
|
|
81
|
+
}
|
|
82
|
+
rmSync(runDir, { recursive: true, force: true })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('the client STAYS attached after the handshake and keeps rendering live frames', async () => {
|
|
86
|
+
expect(sessionAlive(runDir, session)).toBe(true)
|
|
87
|
+
const c = spawnAttachClient(runDir, session)
|
|
88
|
+
|
|
89
|
+
// handshake: the attach line lands (connect + attach + terminal-setup succeeded).
|
|
90
|
+
expect(await waitFor(() => /\[supervisor\] attached to/.test(c.out()))).toBe(true)
|
|
91
|
+
|
|
92
|
+
// THE BUG was a handshake-then-exit within ~ms. Assert the client is STILL attached well past
|
|
93
|
+
// that window AND that live child output (STREAMTICK from the tick runtime) is reaching it —
|
|
94
|
+
// proving the attach-and-stay loop holds, not just that the process happens to linger.
|
|
95
|
+
await sleep(2000)
|
|
96
|
+
expect(c.exited()).toBe(false) // RED on the pre-fix early-resolve (exited ~ms after handshake)
|
|
97
|
+
expect(/STREAMTICK_\d+/.test(c.out())).toBe(true) // live frames flow through the held attach
|
|
98
|
+
|
|
99
|
+
// Ctrl-] (0x1d) detaches: the client exits cleanly, prints the DETACH message (not the
|
|
100
|
+
// misleading "session ended"), and the session keeps running.
|
|
101
|
+
c.sendKey(0x1d)
|
|
102
|
+
expect(await waitFor(() => c.exited())).toBe(true)
|
|
103
|
+
expect(/detached/.test(c.out())).toBe(true)
|
|
104
|
+
expect(/session ended/.test(c.out())).toBe(false) // detach must not claim the session ended
|
|
105
|
+
expect(sessionAlive(runDir, session)).toBe(true) // detach kept the daemon alive
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/supervisor/client.ts
CHANGED
|
@@ -29,6 +29,10 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
29
29
|
process.exit(1)
|
|
30
30
|
}
|
|
31
31
|
let restored = false
|
|
32
|
+
// Set the instant a deliberate Ctrl-] detach begins. `sock.end()` can re-enter the socket close()
|
|
33
|
+
// handler synchronously, so close() must NOT claim "session ended" on an operator-initiated detach
|
|
34
|
+
// (the session keeps running) — it stays silent and lets the detach path own the message + exit.
|
|
35
|
+
let detaching = false
|
|
32
36
|
// writeSync(1, …) works inside a signal handler (no event-loop dependency) — so a
|
|
33
37
|
// SIGHUP/window-close client still resets the terminal before it dies (port-dep #1).
|
|
34
38
|
const restore = (): void => {
|
|
@@ -70,6 +74,7 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
70
74
|
},
|
|
71
75
|
close() {
|
|
72
76
|
restore()
|
|
77
|
+
if (detaching) process.exit(0) // operator detached; the detach path already wrote its message
|
|
73
78
|
process.stdout.write('\r\n[supervisor] session ended.\r\n')
|
|
74
79
|
process.exit(0)
|
|
75
80
|
},
|
|
@@ -82,13 +87,16 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
82
87
|
|
|
83
88
|
process.stdin.on('data', b => {
|
|
84
89
|
if (b.includes(DETACH)) {
|
|
90
|
+
detaching = true
|
|
85
91
|
restore()
|
|
92
|
+
// Write the detach message BEFORE sock.end() — sock.end() can re-enter close() synchronously,
|
|
93
|
+
// and close() now exits silently while `detaching`, so the message must already be out.
|
|
94
|
+
process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
|
|
86
95
|
try {
|
|
87
96
|
sock.end()
|
|
88
97
|
} catch {
|
|
89
98
|
/* */
|
|
90
99
|
}
|
|
91
|
-
process.stdout.write(`\r\n[supervisor] detached — session still running. reattach: supervisor attach ${session}\r\n`)
|
|
92
100
|
process.exit(0)
|
|
93
101
|
}
|
|
94
102
|
sock.write(frame(FRAME_DATA, b))
|
|
@@ -115,4 +123,11 @@ export async function runSupervisorClient(runDir: string, session: string): Prom
|
|
|
115
123
|
}
|
|
116
124
|
process.on('exit', () => restore()) // last-resort sync net
|
|
117
125
|
process.stdout.write(`\x1b[2m[supervisor] attached to "${session}". detach: Ctrl-] (session keeps running)\x1b[0m\r\n`)
|
|
126
|
+
// STAY ATTACHED. The client lives on its handles (raw stdin + the socket); EVERY terminal path
|
|
127
|
+
// (detach Ctrl-], session-end close(), SIGHUP/SIGTERM, socket error) calls process.exit ITSELF.
|
|
128
|
+
// This promise INTENTIONALLY never resolves: returning here would resolve runSupervisorClient the
|
|
129
|
+
// instant the handshake completed, and the CLI caller (`await runSupervisorClient(); return 0` →
|
|
130
|
+
// runCli's `.then(process.exit)`) would tear the just-attached client down in milliseconds — the
|
|
131
|
+
// surface-5 interactive-attach bug. The detached `daemon` case guards itself the same way.
|
|
132
|
+
await new Promise<never>(() => {})
|
|
118
133
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Spawn-flip Ф0b-3 — transport liveness/resolution is HOST-AWARE. The canary exposed it: a genuinely
|
|
2
|
+
// supervisor-HOSTED peer (live supervisor .pid, NO tmux) was seen as OFFLINE by resolveDeliveryTarget/
|
|
3
|
+
// isPeerLive (transport had its OWN tmux-only sessionAlive, separate from the Ф0b-1 lifecycle fix), so
|
|
4
|
+
// routeSend mis-routed a warm hit to a wake and the post-wake verify-before-act false-failed. This
|
|
5
|
+
// binds the fix: a live supervisor .pid → live, even with no tmux.
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { hostRunDir } from '../launch/ptyHost.ts'
|
|
11
|
+
import { isPeerLive, listOnlinePeers } from './index.ts'
|
|
12
|
+
|
|
13
|
+
describe('isPeerLive — host-aware (spawn-flip Ф0b-3)', () => {
|
|
14
|
+
const savedRoot = process.env.IAPEER_ROOT
|
|
15
|
+
let root: string | null = null
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (savedRoot === undefined) delete process.env.IAPEER_ROOT
|
|
18
|
+
else process.env.IAPEER_ROOT = savedRoot
|
|
19
|
+
if (root) rmSync(root, { recursive: true, force: true })
|
|
20
|
+
root = null
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('a LIVE supervisor .pid → live, with NO tmux session (the canary fix)', () => {
|
|
24
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-hostlive-'))
|
|
25
|
+
process.env.IAPEER_ROOT = root
|
|
26
|
+
const runDir = hostRunDir() // resolves under IAPEER_ROOT
|
|
27
|
+
mkdirSync(runDir, { recursive: true })
|
|
28
|
+
writeFileSync(join(runDir, 'codex-hostpeer.pid'), String(process.pid)) // a live pid = a live hosted daemon
|
|
29
|
+
expect(isPeerLive('codex', 'hostpeer')).toBe(true) // host-aware: no tmux, but the supervisor session is alive
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('no supervisor .pid and no tmux → offline', () => {
|
|
33
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-hostlive-'))
|
|
34
|
+
process.env.IAPEER_ROOT = root
|
|
35
|
+
mkdirSync(hostRunDir(), { recursive: true })
|
|
36
|
+
expect(isPeerLive('codex', 'ghostpeer')).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('listOnlinePeers includes a live HOSTED peer (no tmux socket)', () => {
|
|
40
|
+
root = mkdtempSync(join(tmpdir(), 'iapeer-hostlive-'))
|
|
41
|
+
process.env.IAPEER_ROOT = root
|
|
42
|
+
const runDir = hostRunDir()
|
|
43
|
+
mkdirSync(runDir, { recursive: true })
|
|
44
|
+
// supervisor listSessions enumerates by `<identity>.sock`; alive = a live `<identity>.pid`.
|
|
45
|
+
writeFileSync(join(runDir, 'codex-hostpeer.sock'), '')
|
|
46
|
+
writeFileSync(join(runDir, 'codex-hostpeer.pid'), String(process.pid))
|
|
47
|
+
const online = listOnlinePeers()
|
|
48
|
+
expect(online.some(p => p.runtime === 'codex' && p.personality === 'hostpeer')).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
hostRunDir,
|
|
55
55
|
hostSessionAlive,
|
|
56
56
|
hostSessionToken,
|
|
57
|
+
listHostedPeers,
|
|
57
58
|
HOST_COLS,
|
|
58
59
|
HOST_ROWS,
|
|
59
60
|
} from '../launch/ptyHost.ts'
|
|
@@ -113,7 +114,12 @@ function listSessions(sock: string): string[] {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
function sessionAlive(sock: string, address: string): boolean {
|
|
116
|
-
|
|
117
|
+
// SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session has no tmux entry — its liveness is the supervisor
|
|
118
|
+
// daemon (runtime-state), checked FIRST (a cheap pid-file). Non-hosted → the tmux session list, as
|
|
119
|
+
// before (byte-identical). Without this, resolveDeliveryTarget/isPeerLive see a live hosted peer as
|
|
120
|
+
// OFFLINE → routeSend mis-routes a warm hit to a wake and the post-wake verify-before-act false-fails
|
|
121
|
+
// (the canary symptom: "woke but not live" though delivery actually landed). `address` IS the identity.
|
|
122
|
+
return hostSessionAlive(address) || listSessions(sock).includes(address)
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
// ─── Busy-composer detection (human input vs runtime ghost text) ─────────────
|
|
@@ -285,13 +291,13 @@ export function isPeerLive(runtime: string, personality: string, sockDir = resol
|
|
|
285
291
|
}
|
|
286
292
|
|
|
287
293
|
export function listOnlinePeers(sockDir = resolveSockDir()): OnlinePeer[] {
|
|
288
|
-
|
|
294
|
+
const out = new Map<string, OnlinePeer>()
|
|
295
|
+
let entries: string[] = []
|
|
289
296
|
try {
|
|
290
297
|
entries = readdirSync(sockDir)
|
|
291
298
|
} catch {
|
|
292
|
-
return
|
|
299
|
+
/* no tmux sock dir → only supervisor-hosted peers, merged below (never early-return past them) */
|
|
293
300
|
}
|
|
294
|
-
const out = new Map<string, OnlinePeer>()
|
|
295
301
|
for (const entry of entries) {
|
|
296
302
|
const parsedSocket = parseSocketPath(join(sockDir, entry))
|
|
297
303
|
if (!parsedSocket) continue
|
|
@@ -305,6 +311,11 @@ export function listOnlinePeers(sockDir = resolveSockDir()): OnlinePeer[] {
|
|
|
305
311
|
})
|
|
306
312
|
}
|
|
307
313
|
}
|
|
314
|
+
// SPAWN-FLIP Ф0b-3: supervisor-HOSTED peers have no tmux socket, so the scan above misses them —
|
|
315
|
+
// merge in the live hosted sessions so the online scan + the no-runtime resolve see them too.
|
|
316
|
+
for (const h of listHostedPeers()) {
|
|
317
|
+
out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
|
|
318
|
+
}
|
|
308
319
|
return Array.from(out.values()).sort(
|
|
309
320
|
(a, b) =>
|
|
310
321
|
a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
|