@agfpd/iapeer 0.2.63 → 0.2.65
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/cli.test.ts +2 -2
- package/src/cli/index.ts +16 -3
- package/src/cli/listTui.ts +7 -0
- package/src/daemon/main.ts +4 -0
- package/src/launch/hostComposerOccupied.test.ts +63 -0
- package/src/launch/index.ts +81 -25
- package/src/launch/invocation.test.ts +134 -0
- package/src/launch/invocation.ts +56 -0
- package/src/launch/ptyHost.test.ts +33 -0
- package/src/launch/ptyHost.ts +148 -0
- package/src/launch/readyGateModel.test.ts +72 -0
- package/src/launch/readyGateModel.ts +140 -0
- package/src/lifecycle/index.ts +11 -1
- package/src/shadow/enumerate-skip.test.ts +65 -0
- package/src/shadow/index.ts +13 -37
- package/src/shadow/render.ts +60 -0
- package/src/supervisor/attached-marker.test.ts +84 -0
- package/src/supervisor/boot.test.ts +66 -0
- package/src/supervisor/boot.ts +34 -0
- package/src/supervisor/daemon.test.ts +66 -1
- package/src/supervisor/daemon.ts +146 -7
- package/src/supervisor/deliver.test.ts +58 -0
- package/src/supervisor/deliver.ts +120 -0
- package/src/supervisor/index.ts +76 -6
- package/src/supervisor/panelog.test.ts +95 -0
- package/src/supervisor/paths.ts +9 -0
- package/src/supervisor/protocol.test.ts +91 -0
- package/src/supervisor/protocol.ts +49 -0
- package/src/transport/composerQueue.test.ts +30 -0
- package/src/transport/hostControl.test.ts +87 -0
- package/src/transport/hostDeliver.test.ts +102 -0
- package/src/transport/index.ts +186 -24
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Launch-side hosted-session orchestration (cutover Block 2 spawn-flip, Ф0a) — the supervisor-HOST
|
|
2
|
+
// equivalent of the tmux bring-up half of launch(): start the session under the supervisor, wait for
|
|
3
|
+
// readiness off its pane-log model (the supervisor's own boot-driver dismisses startup dialogs), and
|
|
4
|
+
// deliver the first message over its socket. Flag-gated per-peer (.pty-host); when the flag is off
|
|
5
|
+
// this module is never reached and launch is byte-identical (golden).
|
|
6
|
+
//
|
|
7
|
+
// @xterm STAYS OUT of the launch process by construction:
|
|
8
|
+
// - startSupervisorDaemon is dynamic-imported and @xterm-free (daemon.ts is deferred to the detached
|
|
9
|
+
// 'daemon' subprocess) — it only SPAWNS the daemon, @xterm runs there;
|
|
10
|
+
// - the deliver client is a pure-protocol leaf (no @xterm);
|
|
11
|
+
// - sessionAlive comes from client.ts (no @xterm);
|
|
12
|
+
// - readiness reuses paneLogViewport, whose @xterm import is dynamic and only loads flag-on.
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { deliverToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
15
|
+
import { attachedPath, defaultRunDir, pidPath } from '../supervisor/paths.ts'
|
|
16
|
+
import { sessionAlive as hostDaemonAlive } from '../supervisor/client.ts'
|
|
17
|
+
import { killSession as killSupervisorSession } from '../supervisor/index.ts'
|
|
18
|
+
import { paneLogViewport } from './readyGateModel.ts'
|
|
19
|
+
import type { LaunchInvocation } from './invocation.ts'
|
|
20
|
+
import type { RuntimeAdapter } from './types.ts'
|
|
21
|
+
|
|
22
|
+
/** Match the tmux launch geometry (index.ts new-session -x 220 -y 50) so the served child + the
|
|
23
|
+
* pane-log model render at the same size launch reads readiness at. */
|
|
24
|
+
export const HOST_COLS = 220
|
|
25
|
+
export const HOST_ROWS = 50
|
|
26
|
+
|
|
27
|
+
/** Per-peer spawn-flip flag: marker `<logDir>/<identity>.pty-host`. Present → launch hosts the session
|
|
28
|
+
* under the supervisor instead of `tmux new-session`. DISTINCT from `.pty-readygate` (the ready-gate
|
|
29
|
+
* viewport-source flip): a readygate-flipped peer is still tmux-HOSTED and stays under observer
|
|
30
|
+
* cross-check; only a `.pty-host` peer is supervisor-hosted (cross-check retires for it). */
|
|
31
|
+
export function ptyHostEnabled(logDir: string, identity: string): boolean {
|
|
32
|
+
return existsSync(`${logDir}/${identity}.pty-host`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
|
|
36
|
+
export function hostRunDir(): string {
|
|
37
|
+
return defaultRunDir()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is there a LIVE supervisor session for this identity? This is the RUNTIME-STATE host detector the
|
|
42
|
+
* warm surfaces (liveness / deliver / reap / attach / observer) route on — NOT the `.pty-host` marker.
|
|
43
|
+
* The marker is spawn-INTENT (read by launch at spawn); deriving warm routing from the live session
|
|
44
|
+
* means rolling the marker back (rm) on a STILL-RUNNING hosted session never tears it (rm affects only
|
|
45
|
+
* the NEXT spawn). Rollback of a RUNNING hosted session is therefore kill-then-rm, NOT rm-alone.
|
|
46
|
+
*/
|
|
47
|
+
export function hostSessionAlive(identity: string): boolean {
|
|
48
|
+
return hostDaemonAlive(hostRunDir(), identity)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up). */
|
|
52
|
+
export function killPtyHost(identity: string): void {
|
|
53
|
+
killSupervisorSession(hostRunDir(), identity)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is a HUMAN operator attached to this hosted session right now? (spawn-flip Ф0b-3 slice 3c). The
|
|
58
|
+
* supervisor daemon maintains a `.attached` marker present iff ≥1 client is connected (synced on every
|
|
59
|
+
* client add/detach incl. abnormal close/error/drain, and cleared on daemon start so a crashed
|
|
60
|
+
* predecessor's stale marker never lies). The warm-deliver busy-composer hold gates on THIS: hold for
|
|
61
|
+
* a human's unfinished composer input, but NEVER for the AI's own composer output when no one is
|
|
62
|
+
* attached. The host equivalent of `hasAttachedTmuxClient`.
|
|
63
|
+
*/
|
|
64
|
+
export function hasAttachedSupervisorClient(identity: string): boolean {
|
|
65
|
+
return existsSync(attachedPath(hostRunDir(), identity))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A session-REPLACEMENT token for a hosted session (spawn-flip Ф0b-3 slice 3c): the supervisor
|
|
70
|
+
* daemon's pid. A `/new` or restart spawns a NEW daemon (new pid), so a message queued against the old
|
|
71
|
+
* session FAILS rather than landing in a fresh same-named session — the host equivalent of the tmux
|
|
72
|
+
* `session_id:pane_id` token. null when no daemon pid is readable.
|
|
73
|
+
*/
|
|
74
|
+
export function hostSessionToken(identity: string): string | null {
|
|
75
|
+
try {
|
|
76
|
+
const pid = readFileSync(pidPath(hostRunDir(), identity), 'utf8').trim()
|
|
77
|
+
return pid ? `host:${pid}` : null
|
|
78
|
+
} catch {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface HostStartResult {
|
|
84
|
+
ok: boolean
|
|
85
|
+
error?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Bring up the hosted session: start a detached supervisor daemon serving the composed invocation
|
|
90
|
+
* (argv+env from buildLaunchInvocation), writing the pane-log at `paneLogPath`. Returns ok/fail so
|
|
91
|
+
* launch can fall back to `tmux new-session` on failure (the unconditional spawn-fallback — a flipped
|
|
92
|
+
* peer is never worse than tmux ON SPAWN).
|
|
93
|
+
*/
|
|
94
|
+
export async function startPtyHost(b: {
|
|
95
|
+
identity: string
|
|
96
|
+
runtime: string
|
|
97
|
+
cwd: string
|
|
98
|
+
paneLogPath: string
|
|
99
|
+
exitLogPath?: string
|
|
100
|
+
invocation: LaunchInvocation
|
|
101
|
+
}): Promise<HostStartResult> {
|
|
102
|
+
try {
|
|
103
|
+
const { startSupervisorDaemon } = await import('../supervisor/index.ts')
|
|
104
|
+
const r = startSupervisorDaemon({
|
|
105
|
+
session: b.identity,
|
|
106
|
+
runtime: b.runtime,
|
|
107
|
+
runDir: hostRunDir(),
|
|
108
|
+
serve: {
|
|
109
|
+
argv: b.invocation.argv,
|
|
110
|
+
env: b.invocation.env as Record<string, string>,
|
|
111
|
+
cwd: b.cwd,
|
|
112
|
+
paneLogPath: b.paneLogPath,
|
|
113
|
+
exitLogPath: b.exitLogPath,
|
|
114
|
+
cols: HOST_COLS,
|
|
115
|
+
rows: HOST_ROWS,
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
return r.state === 'failed' ? { ok: false, error: r.detail ?? 'supervisor host failed to come up' } : { ok: true }
|
|
119
|
+
} catch (e) {
|
|
120
|
+
return { ok: false, error: `pty-host bringup threw: ${(e as Error).message}` }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wait for the hosted input surface to be ready: poll the pane-log model and run the adapter's
|
|
126
|
+
* isInputReady (the supervisor's boot-driver answers dialogs internally, so launch does NOT drive
|
|
127
|
+
* them). Returns true when ready, false on deadline / session death. Mirrors the tmux boot loop's
|
|
128
|
+
* 2 s cadence + bootDeadline.
|
|
129
|
+
*/
|
|
130
|
+
export async function waitHostReady(
|
|
131
|
+
b: { identity: string; logDir: string; adapter: RuntimeAdapter; bootDeadlineSecs: number },
|
|
132
|
+
sleep: (ms: number) => Promise<void>,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const log = `${b.logDir}/${b.identity}.log`
|
|
135
|
+
const iters = Math.max(1, Math.ceil(b.bootDeadlineSecs / 2))
|
|
136
|
+
for (let i = 0; i < iters; i++) {
|
|
137
|
+
await sleep(2000)
|
|
138
|
+
if (!hostSessionAlive(b.identity)) return false
|
|
139
|
+
const view = await paneLogViewport(log, HOST_COLS, HOST_ROWS)
|
|
140
|
+
if (view && b.adapter.isInputReady(view)) return true
|
|
141
|
+
}
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Deliver the first/next message to the hosted session over its supervisor socket. */
|
|
146
|
+
export function deliverHosted(identity: string, message: string): Promise<DeliverResult> {
|
|
147
|
+
return deliverToHost(hostRunDir(), identity, message)
|
|
148
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { logReadyGateCrossCheck, readyGatePtyFlipEnabled, readyGateViewport } from './readyGateModel.ts'
|
|
6
|
+
|
|
7
|
+
// The Ф1 ready-gate source swap is DARK by default and falls back to capture-pane UNCONDITIONALLY,
|
|
8
|
+
// so the flip can never make readiness worse than tmux today. These prove exactly that contract.
|
|
9
|
+
const tmps: string[] = []
|
|
10
|
+
const mkLogDir = (): string => {
|
|
11
|
+
const d = mkdtempSync(join(tmpdir(), 'iapeer-rgm-'))
|
|
12
|
+
tmps.push(d)
|
|
13
|
+
return d
|
|
14
|
+
}
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
while (tmps.length) rmSync(tmps.pop()!, { recursive: true, force: true })
|
|
17
|
+
})
|
|
18
|
+
const FALLBACK = '<<capture-pane-fallback>>'
|
|
19
|
+
|
|
20
|
+
describe('readyGatePtyFlipEnabled (per-peer flip flag, canary-first)', () => {
|
|
21
|
+
test('absent marker → off; present marker → on', () => {
|
|
22
|
+
const d = mkLogDir()
|
|
23
|
+
expect(readyGatePtyFlipEnabled(d, 'codex-x')).toBe(false)
|
|
24
|
+
writeFileSync(join(d, 'codex-x.pty-readygate'), '')
|
|
25
|
+
expect(readyGatePtyFlipEnabled(d, 'codex-x')).toBe(true)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('readyGateViewport (Ф1 viewport-source swap, tmux-fallback)', () => {
|
|
30
|
+
test('flag OFF → the capture-pane fallback VERBATIM (byte-identical to today, no model built)', async () => {
|
|
31
|
+
const d = mkLogDir()
|
|
32
|
+
expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })).toBe(FALLBACK)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('flag ON + a pane-log → the MODEL viewport (source swapped), not the fallback', async () => {
|
|
36
|
+
const d = mkLogDir()
|
|
37
|
+
writeFileSync(join(d, 'codex-x.pty-readygate'), '')
|
|
38
|
+
writeFileSync(join(d, 'codex-x.log'), '\x1b[24;1H› ready prompt here') // render a codex '›' prompt row
|
|
39
|
+
const v = await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })
|
|
40
|
+
expect(v).not.toBe(FALLBACK) // the source was swapped to the model
|
|
41
|
+
expect(v).toContain('› ready prompt here') // the model rendered the prompt the predicate keys on
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('flag ON but the pane-log is MISSING → fallback (never worse than tmux)', async () => {
|
|
45
|
+
const d = mkLogDir()
|
|
46
|
+
writeFileSync(join(d, 'codex-x.pty-readygate'), '')
|
|
47
|
+
expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 80, rows: 24 })).toBe(FALLBACK)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('flag ON but geometry is unknown (0x0) → fallback', async () => {
|
|
51
|
+
const d = mkLogDir()
|
|
52
|
+
writeFileSync(join(d, 'codex-x.pty-readygate'), '')
|
|
53
|
+
writeFileSync(join(d, 'codex-x.log'), '\x1b[24;1H› ready')
|
|
54
|
+
expect(await readyGateViewport({ logDir: d, identity: 'codex-x', fallback: FALLBACK, cols: 0, rows: 0 })).toBe(FALLBACK)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('logReadyGateCrossCheck (serving-path soak observability)', () => {
|
|
59
|
+
test('appends a record with diverged computed; agreement → false, mismatch → true', () => {
|
|
60
|
+
const d = mkLogDir()
|
|
61
|
+
logReadyGateCrossCheck(d, { identity: 'codex-x', modelReady: true, captureReady: false, geom: '220x50', nowISO: '2026-06-14T00:00:00Z' })
|
|
62
|
+
logReadyGateCrossCheck(d, { identity: 'codex-x', modelReady: true, captureReady: true, geom: '220x50', nowISO: '2026-06-14T00:00:05Z' })
|
|
63
|
+
const lines = readFileSync(join(d, 'readygate-flip.jsonl'), 'utf8').trim().split('\n').map(l => JSON.parse(l))
|
|
64
|
+
expect(lines[0].diverged).toBe(true) // model ahead of capture
|
|
65
|
+
expect(lines[0].modelReady).toBe(true)
|
|
66
|
+
expect(lines[0].captureReady).toBe(false)
|
|
67
|
+
expect(lines[1].diverged).toBe(false) // agreement
|
|
68
|
+
})
|
|
69
|
+
test('no event-log dir → no-op (never blocks the ready-gate)', () => {
|
|
70
|
+
expect(() => logReadyGateCrossCheck(undefined, { identity: 'x', modelReady: true, captureReady: true, geom: '1x1', nowISO: 't' })).not.toThrow()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Ф1 READY-GATE viewport-source FLIP (cutover Block 3) — DARK / flag-gated, canary-first.
|
|
2
|
+
//
|
|
3
|
+
// The cold-wake ready-gate (launch index.ts step 6) decides "is the input surface ready?" by
|
|
4
|
+
// running adapter.isInputReady on a VIEWPORT. Today that viewport is a `tmux capture-pane` scrape.
|
|
5
|
+
// This is the FIRST migration mutation: swap the viewport SOURCE to the pty-MODEL (an @xterm/headless
|
|
6
|
+
// emulation of the daemon's pane-log — the SAME raw child bytes tmux renders), keeping the SAME
|
|
7
|
+
// production predicate (adapter.isInputReady) and an UNCONDITIONAL capture-pane fallback.
|
|
8
|
+
//
|
|
9
|
+
// Why this is low-radius (boris's frame): it is a viewport-SOURCE swap, NOT a predicate change.
|
|
10
|
+
// The model verdict was validated 0-divergence vs capture-pane on the readiness predicate by the
|
|
11
|
+
// burn-in gate (incl. the codex resume-with-history splash-off-screen center cell) before any peer
|
|
12
|
+
// is flipped. It touches ONLY the isInputReady INPUT — dialog detection upstream and the delivery +
|
|
13
|
+
// delivery-confirm downstream (submitIntoTui's submit-landed + deliverViaTmux's transcript-mtime
|
|
14
|
+
// advance, the "real model advancement" final-arbiter) are untouched, so the canonical
|
|
15
|
+
// harmless-false-glyph property is preserved by construction.
|
|
16
|
+
//
|
|
17
|
+
// FLAG: a per-peer marker `<logDir>/<identity>.pty-readygate`. Absent → capture-pane (byte-identical
|
|
18
|
+
// to today). Present → the model viewport. Per-peer so the first flip is a single canary with the
|
|
19
|
+
// whole fleet still on tmux; dynamic (touch/rm, no daemon restart). @xterm is dynamic-imported ONLY
|
|
20
|
+
// when the flag is set, so the launch hot path stays @xterm-free with the flip off.
|
|
21
|
+
|
|
22
|
+
import { appendFileSync, closeSync, existsSync, fstatSync, openSync, readSync } from 'node:fs'
|
|
23
|
+
import { join } from 'node:path'
|
|
24
|
+
import type { Terminal } from '@xterm/headless' // TYPE-ONLY (erased) — the runtime @xterm load is the dynamic import below
|
|
25
|
+
|
|
26
|
+
const SEED_BYTES = 4 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SERVING-path cross-check log (the post-flip soak gate). When a peer is flipped, the MODEL verdict
|
|
30
|
+
* is AUTHORITATIVE in the cold-wake ready-gate; this records, per evaluation, what capture-pane WOULD
|
|
31
|
+
* have said — so a soak can prove 0 model-vs-capture divergence IN THE SERVING PATH (the read-only
|
|
32
|
+
* burn-in measures continuously but never drove the actual ready decision). Append-only jsonl under
|
|
33
|
+
* the event-log dir. Best-effort: a log hiccup never affects the ready decision. No-op without a dir.
|
|
34
|
+
*/
|
|
35
|
+
export function logReadyGateCrossCheck(
|
|
36
|
+
eventLogDir: string | undefined,
|
|
37
|
+
rec: { identity: string; modelReady: boolean; captureReady: boolean; geom: string; nowISO: string },
|
|
38
|
+
): void {
|
|
39
|
+
if (!eventLogDir) return
|
|
40
|
+
try {
|
|
41
|
+
appendFileSync(
|
|
42
|
+
join(eventLogDir, 'readygate-flip.jsonl'),
|
|
43
|
+
JSON.stringify({
|
|
44
|
+
ts: rec.nowISO,
|
|
45
|
+
identity: rec.identity,
|
|
46
|
+
modelReady: rec.modelReady,
|
|
47
|
+
captureReady: rec.captureReady,
|
|
48
|
+
diverged: rec.modelReady !== rec.captureReady,
|
|
49
|
+
geom: rec.geom,
|
|
50
|
+
}) + '\n',
|
|
51
|
+
)
|
|
52
|
+
} catch {
|
|
53
|
+
/* observability only — never block the ready-gate */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Per-peer ready-gate flip flag: the marker `<logDir>/<identity>.pty-readygate`. */
|
|
58
|
+
export function readyGatePtyFlipEnabled(logDir: string, identity: string): boolean {
|
|
59
|
+
return existsSync(`${logDir}/${identity}.pty-readygate`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ReadyGateViewportOpts {
|
|
63
|
+
logDir: string
|
|
64
|
+
identity: string
|
|
65
|
+
/** The capture-pane scrape — returned verbatim when the flag is off OR any model build fails. */
|
|
66
|
+
fallback: string
|
|
67
|
+
cols: number
|
|
68
|
+
rows: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the ready-gate viewport. Flag OFF → `fallback` (capture-pane) verbatim — zero work, the
|
|
73
|
+
* caller never even reaches here unless it pre-checked the flag, but this stays safe regardless.
|
|
74
|
+
* Flag ON → the pty-model viewport as plain text. ANY failure (unreadable/short log, @xterm hiccup)
|
|
75
|
+
* → `fallback`: the flip can never make readiness WORSE than capture-pane today (tmux is the floor).
|
|
76
|
+
*/
|
|
77
|
+
export async function readyGateViewport(opts: ReadyGateViewportOpts): Promise<string> {
|
|
78
|
+
if (!readyGatePtyFlipEnabled(opts.logDir, opts.identity)) return opts.fallback
|
|
79
|
+
if (!(opts.cols > 0 && opts.rows > 0)) return opts.fallback
|
|
80
|
+
const view = await paneLogViewport(`${opts.logDir}/${opts.identity}.log`, opts.cols, opts.rows)
|
|
81
|
+
return view ?? opts.fallback // tmux-fallback is unconditional — never worse than today
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read a pane-log's tail into a headless @xterm and render the viewport as plain text — the SAME
|
|
86
|
+
* 0-divergence-validated model the ready-gate flip uses, shared with the spawn-flip hosted path (a
|
|
87
|
+
* supervisor-HOSTED session has no capture-pane, so its pane-log model is the ONLY readiness source).
|
|
88
|
+
* @xterm is dynamic-imported here so a flag-off launch never loads it. Returns null on any failure
|
|
89
|
+
* (unreadable/short log, @xterm hiccup) — the caller decides the fallback (capture-pane for the
|
|
90
|
+
* readygate flip; a retry/fail for hosted).
|
|
91
|
+
*/
|
|
92
|
+
async function loadPaneLogModel(log: string, cols: number, rows: number): Promise<Terminal | null> {
|
|
93
|
+
if (!(cols > 0 && rows > 0) || !existsSync(log)) return null
|
|
94
|
+
try {
|
|
95
|
+
const { Terminal } = await import('@xterm/headless')
|
|
96
|
+
const fd = openSync(log, 'r')
|
|
97
|
+
try {
|
|
98
|
+
const sz = fstatSync(fd).size
|
|
99
|
+
const off = Math.max(0, sz - SEED_BYTES)
|
|
100
|
+
const buf = Buffer.alloc(sz - off)
|
|
101
|
+
readSync(fd, buf, 0, buf.length, off)
|
|
102
|
+
const term = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 3000 })
|
|
103
|
+
await new Promise<void>(res => term.write(buf, () => res()))
|
|
104
|
+
return term
|
|
105
|
+
} finally {
|
|
106
|
+
closeSync(fd)
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function paneLogViewport(log: string, cols: number, rows: number): Promise<string | null> {
|
|
114
|
+
const term = await loadPaneLogModel(log, cols, rows)
|
|
115
|
+
if (!term) return null
|
|
116
|
+
const { modelToPlainText } = await import('../shadow/render.ts')
|
|
117
|
+
return modelToPlainText(term, cols, rows)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Spawn-flip Ф0b-2 hosted composer-occupancy guard (slice 2): does the hosted session's composer hold
|
|
122
|
+
* human input? Reads the pane-log model — the SAME raw child bytes tmux renders — and runs the burn-in-
|
|
123
|
+
* validated `composerOccupancyFromModel` (REUSED, not reimplemented). The hosted equivalent of
|
|
124
|
+
* `targetComposerBlocksDelivery`'s capture-pane occupancy. @xterm loads dynamically here (flag-on
|
|
125
|
+
* hosted only; the leaf `composerOccupancyFromModel` import is type-only at runtime, so the warm path
|
|
126
|
+
* stays @xterm-free with the flip off). UNCERTAINTY → BUSY (true): a model-build failure mirrors the
|
|
127
|
+
* tmux predicate's capture-fail-is-busy stance — prefer a false-busy hold over a false-free paste into
|
|
128
|
+
* a human composer.
|
|
129
|
+
*
|
|
130
|
+
* NB (sequence, boris): this is the DETECTION primitive. The end-to-end gate (attached-supervisor-
|
|
131
|
+
* client + enqueue + host-aware drain) lands with the attach client in Ф0b-3 — until a human can
|
|
132
|
+
* attach to a hosted session there is no human composer to protect. The caller composes the pane-log
|
|
133
|
+
* path from logDir+identity (the threaded `logDir` finding), mirroring `waitHostReady`.
|
|
134
|
+
*/
|
|
135
|
+
export async function paneLogComposerOccupied(log: string, cols: number, rows: number, runtime: string): Promise<boolean> {
|
|
136
|
+
const term = await loadPaneLogModel(log, cols, rows)
|
|
137
|
+
if (!term) return true // uncertainty → busy: never paste into a maybe-occupied composer
|
|
138
|
+
const { composerOccupancyFromModel } = await import('../shadow/render.ts')
|
|
139
|
+
return composerOccupancyFromModel(term, cols, rows, runtime)
|
|
140
|
+
}
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
} from '../launch/index.ts'
|
|
49
49
|
import { composeSystemPrompt, gatherPromptInput } from '../launch/composeSystemPrompt.ts'
|
|
50
50
|
import { dismissCanary, ensureServerCanary, signalCanaryClean } from '../launch/canary.ts'
|
|
51
|
+
import { hostSessionAlive, killPtyHost } from '../launch/ptyHost.ts'
|
|
51
52
|
import { appendLifecycleEvent, superviseLogVerbose } from './eventlog.ts'
|
|
52
53
|
|
|
53
54
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -650,7 +651,12 @@ function tmux(sock: string, ...args: string[]): { ok: boolean; out: string; err:
|
|
|
650
651
|
return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
|
|
651
652
|
}
|
|
652
653
|
function sessionAlive(sock: string, identity: string): boolean {
|
|
653
|
-
|
|
654
|
+
// RUNTIME-STATE host detection (spawn-flip Ф0b), NOT the `.pty-host` marker: a peer with a LIVE
|
|
655
|
+
// supervisor session is hosted-and-alive; otherwise fall to tmux has-session. Routing liveness on
|
|
656
|
+
// the live supervisor session (not the spawn-intent marker) makes a marker rollback safe on a
|
|
657
|
+
// still-running hosted session — rm-marker only changes the NEXT spawn, never the running routing.
|
|
658
|
+
// Non-hosted peers: hostSessionAlive is a cheap pid-file miss → identical result to today (tmux).
|
|
659
|
+
return hostSessionAlive(identity) || tmux(sock, 'has-session', '-t', identity).ok
|
|
654
660
|
}
|
|
655
661
|
|
|
656
662
|
/** Death-class tag for a gone session (live case: iapeer-memory 10.06 — the WHOLE
|
|
@@ -1049,6 +1055,10 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
|
|
|
1049
1055
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1050
1056
|
|
|
1051
1057
|
export function killSession(sock: string, identity: string): void {
|
|
1058
|
+
// Hosted peer (spawn-flip Ф0b): SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and
|
|
1059
|
+
// removes the sock/pid/serve-spec. The tmux teardown below is then a harmless no-op (no tmux session
|
|
1060
|
+
// on this sock); for a non-hosted peer it is the real reap, byte-identical to today.
|
|
1061
|
+
if (hostSessionAlive(identity)) killPtyHost(identity)
|
|
1052
1062
|
tmux(sock, 'kill-session', '-t', identity)
|
|
1053
1063
|
const sessions = tmux(sock, 'list-sessions', '-F', '#{session_name}').out
|
|
1054
1064
|
if (!sessions.trim()) {
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
|
|
2
|
+
//
|
|
3
|
+
// The observer compares the pty-model vs LIVE TMUX. A `.pty-host`-designated peer has no tmux to
|
|
4
|
+
// compare against, and during a flip a lingering tmux socket for it would surface FALSE divergences.
|
|
5
|
+
// This proves the explicit skip fires IN enumerate even in that race (a live tmux session + a pane-log
|
|
6
|
+
// present): the marked peer is NOT enrolled, while an unmarked sibling IS. Real-tmux, guarded (a clean
|
|
7
|
+
// CI runner has no tmux; the release machine has it — same pattern as routesend-wake).
|
|
8
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
9
|
+
import { spawnSync } from 'node:child_process'
|
|
10
|
+
import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { buildSocketPath } from '../core/socket.ts'
|
|
14
|
+
import { enumerate } from './index.ts'
|
|
15
|
+
|
|
16
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
17
|
+
|
|
18
|
+
let sockDir: string
|
|
19
|
+
let logDir: string
|
|
20
|
+
const KEEP = 'keepobs' // unmarked → observed
|
|
21
|
+
const SKIP = 'skipobs' // .pty-host marker → skipped
|
|
22
|
+
const addrOf = (p: string) => `codex-${p}`
|
|
23
|
+
const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
|
|
24
|
+
|
|
25
|
+
function startTmux(personality: string): void {
|
|
26
|
+
const r = spawnSync(
|
|
27
|
+
'tmux',
|
|
28
|
+
['-S', sockOf(personality), 'new-session', '-d', '-x', '80', '-y', '24', '-s', addrOf(personality), 'cat'],
|
|
29
|
+
{ encoding: 'utf8' },
|
|
30
|
+
)
|
|
31
|
+
if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
|
|
32
|
+
}
|
|
33
|
+
function killTmux(personality: string): void {
|
|
34
|
+
spawnSync('tmux', ['-S', sockOf(personality), 'kill-server'], { stdio: 'ignore' })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
beforeAll(() => {
|
|
38
|
+
sockDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-sock-'))
|
|
39
|
+
logDir = mkdtempSync(join(tmpdir(), 'iapeer-obs-log-'))
|
|
40
|
+
if (!tmuxAvailable) return
|
|
41
|
+
// Both peers: a live tmux session + a pane-log (so enrolment is otherwise satisfied).
|
|
42
|
+
for (const p of [KEEP, SKIP]) {
|
|
43
|
+
startTmux(p)
|
|
44
|
+
writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
|
|
45
|
+
}
|
|
46
|
+
// SKIP additionally carries the `.pty-host` designation — even WITH a live tmux session, the observer
|
|
47
|
+
// must not enroll it (the flip-transition race the skip guards against).
|
|
48
|
+
writeFileSync(join(logDir, `${addrOf(SKIP)}.pty-host`), '')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
afterAll(() => {
|
|
52
|
+
if (tmuxAvailable) for (const p of [KEEP, SKIP]) killTmux(p)
|
|
53
|
+
rmSync(sockDir, { recursive: true, force: true })
|
|
54
|
+
rmSync(logDir, { recursive: true, force: true })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe.if(tmuxAvailable)('observer enumerate skips supervisor-hosted (.pty-host) peers', () => {
|
|
58
|
+
test('a .pty-host peer is NOT enrolled even with a live tmux session + pane-log; an unmarked one IS', () => {
|
|
59
|
+
const models = new Map<string, any>()
|
|
60
|
+
enumerate(models, { sockDir, logDir, eventLogDir: logDir })
|
|
61
|
+
expect(models.has(addrOf(KEEP))).toBe(true) // unmarked → observed (live tmux + log present)
|
|
62
|
+
expect(models.has(addrOf(SKIP))).toBe(false) // .pty-host designation → skipped despite live tmux
|
|
63
|
+
for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
|
|
64
|
+
})
|
|
65
|
+
})
|
package/src/shadow/index.ts
CHANGED
|
@@ -17,50 +17,20 @@ import { openSync, closeSync, fstatSync, readSync, existsSync, appendFileSync, w
|
|
|
17
17
|
import { join } from 'node:path'
|
|
18
18
|
import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transport/index.ts'
|
|
19
19
|
import { getAdapter } from '../launch/index.ts'
|
|
20
|
+
import { ptyHostEnabled } from '../launch/ptyHost.ts' // spawn-flip Ф0b-3: the observer skips supervisor-hosted peers
|
|
20
21
|
import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
|
|
21
22
|
import type { Runtime } from '../core/constants.ts'
|
|
22
23
|
import { GateTracker, classifyDirection, evaluateReadyGateFlipEligibility, VERDICTS } from './gate.ts'
|
|
24
|
+
// modelToPlainText lives in the leaf render.ts (shared with readyGateModel + the supervisor
|
|
25
|
+
// boot-driver); re-exported here so existing importers of './index.ts' keep resolving.
|
|
26
|
+
import { composerOccupancyFromModel, modelToPlainText } from './render.ts'
|
|
27
|
+
export { composerOccupancyFromModel, modelToPlainText }
|
|
23
28
|
|
|
24
29
|
const SEED_BYTES = 4 * 1024 * 1024
|
|
25
|
-
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
26
30
|
const pidAlive = (pid: number): boolean => {
|
|
27
31
|
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
28
32
|
}
|
|
29
33
|
|
|
30
|
-
/** PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose
|
|
31
|
-
* first visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246)
|
|
32
|
-
* visible cell after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it. */
|
|
33
|
-
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
34
|
-
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|
|
35
|
-
let py = -1, px = -1
|
|
36
|
-
for (let y = top; y < bot; y++) {
|
|
37
|
-
const l = b.getLine(y); if (!l) continue
|
|
38
|
-
let fx = -1
|
|
39
|
-
for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
|
|
40
|
-
if (fx < 0) continue
|
|
41
|
-
if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
|
|
42
|
-
}
|
|
43
|
-
if (py < 0) return false
|
|
44
|
-
const l = b.getLine(py)!
|
|
45
|
-
for (let x = px + 1; x < cols; x++) {
|
|
46
|
-
const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
|
|
47
|
-
if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
|
|
48
|
-
}
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** The model's viewport as PLAIN text (mimics `capture-pane -p`, the input isInputReady takes). */
|
|
53
|
-
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
54
|
-
const b = term.buffer.active, lines: string[] = []
|
|
55
|
-
for (let y = b.baseY; y < b.baseY + rows; y++) {
|
|
56
|
-
const l = b.getLine(y); if (!l) { lines.push(''); continue }
|
|
57
|
-
let s = ''
|
|
58
|
-
for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
|
|
59
|
-
lines.push(s.replace(/\s+$/, ''))
|
|
60
|
-
}
|
|
61
|
-
return lines.join('\n')
|
|
62
|
-
}
|
|
63
|
-
|
|
64
34
|
interface Obs { addr: string; sock: string; runtime: Runtime; cols: number; rows: number; term: Terminal; fd: number; offset: number }
|
|
65
35
|
|
|
66
36
|
export interface ShadowOptions {
|
|
@@ -84,12 +54,18 @@ function feed(o: Obs): Promise<number> {
|
|
|
84
54
|
return new Promise(res => o.term.write(buf, () => res(buf.length)))
|
|
85
55
|
}
|
|
86
56
|
|
|
87
|
-
function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
57
|
+
export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
|
|
88
58
|
let peers: { runtime: Runtime; personality: string }[] = []
|
|
89
59
|
try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
|
|
90
60
|
const live = new Set<string>()
|
|
91
61
|
for (const p of peers) {
|
|
92
|
-
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
62
|
+
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
63
|
+
// SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
|
|
64
|
+
// LIVE TMUX; a `.pty-host`-designated peer has no tmux to compare against, and during a flip a
|
|
65
|
+
// lingering tmux socket for it would surface false divergences. Skip by the migration DESIGNATION
|
|
66
|
+
// (marker), BEFORE `live` — a peer flipped while enrolled then falls out of `live` and is reaped.
|
|
67
|
+
if (ptyHostEnabled(opts.logDir, addr)) continue
|
|
68
|
+
live.add(addr)
|
|
93
69
|
if (models.has(addr)) continue
|
|
94
70
|
try {
|
|
95
71
|
const sock = buildSocketPath(p.runtime, p.personality, opts.sockDir)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Leaf render helper: @xterm model → plain text. Extracted from shadow/index.ts so consumers
|
|
2
|
+
// (the shadow observer, the Ф1 ready-gate flip, and the Block-2 supervisor boot-driver) share ONE
|
|
3
|
+
// definition without importing the observer's transport/launch graph. Type-only @xterm import →
|
|
4
|
+
// no runtime dependency beyond the Terminal instance the caller already holds; hermetically testable.
|
|
5
|
+
import type { Terminal } from '@xterm/headless'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The model's VISIBLE viewport (baseY..baseY+rows) as PLAIN text — mimics `tmux capture-pane -p`,
|
|
9
|
+
* the surface the runtime adapters' pane predicates (isInputReady / bootDialogKeys) take. Trailing
|
|
10
|
+
* whitespace per line is trimmed (capture-pane does the same), cells coalesced left-to-right.
|
|
11
|
+
*
|
|
12
|
+
* 0-divergence-validated vs capture-pane on READY screens by the ready-gate burn-in; the supervisor
|
|
13
|
+
* boot-driver additionally validates it on BOOT-DIALOG screens (a different screen state the
|
|
14
|
+
* ready-gate observation never saw).
|
|
15
|
+
*/
|
|
16
|
+
export function modelToPlainText(term: Terminal, cols: number, rows: number): string {
|
|
17
|
+
const b = term.buffer.active,
|
|
18
|
+
lines: string[] = []
|
|
19
|
+
for (let y = b.baseY; y < b.baseY + rows; y++) {
|
|
20
|
+
const l = b.getLine(y)
|
|
21
|
+
if (!l) {
|
|
22
|
+
lines.push('')
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
let s = ''
|
|
26
|
+
for (let x = 0; x < cols; x++) s += l.getCell(x)?.getChars() || ' '
|
|
27
|
+
lines.push(s.replace(/\s+$/, ''))
|
|
28
|
+
}
|
|
29
|
+
return lines.join('\n')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* PTY composer-occupancy verdict: getCell on the model's viewport composer row (the row whose first
|
|
36
|
+
* visible cell is the prompt glyph); human input = a non-ghost (non-dim, non-grey246) visible cell
|
|
37
|
+
* after it. Mirrors composerCaptureHasHumanInput — validated 0-divergence vs it by the burn-in. Lives
|
|
38
|
+
* in this leaf (type-only @xterm) so the spawn-flip hosted occupancy guard can reuse it WITHOUT
|
|
39
|
+
* pulling the @xterm-loading observer graph into the warm-deliver path; the Terminal instance is
|
|
40
|
+
* built by the caller (paneLogComposerOccupied dynamic-imports @xterm). Re-exported from
|
|
41
|
+
* shadow/index.ts so the observer's existing importers keep resolving.
|
|
42
|
+
*/
|
|
43
|
+
export function composerOccupancyFromModel(term: Terminal, cols: number, rows: number, runtime: string): boolean {
|
|
44
|
+
const b = term.buffer.active, top = b.baseY, bot = b.baseY + rows, g = promptGlyph(runtime)
|
|
45
|
+
let py = -1, px = -1
|
|
46
|
+
for (let y = top; y < bot; y++) {
|
|
47
|
+
const l = b.getLine(y); if (!l) continue
|
|
48
|
+
let fx = -1
|
|
49
|
+
for (let x = 0; x < cols; x++) { if ((l.getCell(x)?.getChars() || ' ').trim() !== '') { fx = x; break } }
|
|
50
|
+
if (fx < 0) continue
|
|
51
|
+
if (l.getCell(fx)!.getChars() === g) { py = y; px = fx }
|
|
52
|
+
}
|
|
53
|
+
if (py < 0) return false
|
|
54
|
+
const l = b.getLine(py)!
|
|
55
|
+
for (let x = px + 1; x < cols; x++) {
|
|
56
|
+
const c = l.getCell(x); if ((c?.getChars() || ' ').trim() === '') continue
|
|
57
|
+
if (!(c!.isDim() || (c!.isFgPalette() && c!.getFgColor() === 246))) return true
|
|
58
|
+
}
|
|
59
|
+
return false
|
|
60
|
+
}
|