@agfpd/iapeer 0.2.64 → 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 +57 -24
- 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.ts +47 -6
- 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,95 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { Terminal } from '@xterm/headless'
|
|
7
|
+
import { modelToPlainText } from '../shadow/render.ts'
|
|
8
|
+
import { killSession, startSupervisorDaemon } from './index.ts'
|
|
9
|
+
|
|
10
|
+
// Slice c — the supervisor pane-log must be a drop-in for tmux pipe-pane: the SAME raw child bytes to
|
|
11
|
+
// <identity>.log (so observer/shadow/readyGateViewport read a served session identically), AND a model
|
|
12
|
+
// built from it must render what tmux's capture-pane shows. Live tmux + Bun-native pty → guarded.
|
|
13
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
14
|
+
const [maj, min, pat] = (Bun.version || '0.0.0').split('.').map(Number) as [number, number, number]
|
|
15
|
+
const bunPty = maj > 1 || (maj === 1 && (min > 3 || (min === 3 && pat >= 5)))
|
|
16
|
+
const d = tmuxAvailable && bunPty ? describe : describe.skip
|
|
17
|
+
|
|
18
|
+
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
|
|
19
|
+
const readBuf = (p: string): Buffer => (existsSync(p) ? readFileSync(p) : Buffer.alloc(0))
|
|
20
|
+
const modelOf = (bytes: Buffer, cols: number, rows: number): Promise<string> => {
|
|
21
|
+
const t = new Terminal({ cols, rows, allowProposedApi: true, scrollback: 1000 })
|
|
22
|
+
return new Promise<string>(res => t.write(bytes, () => res(modelToPlainText(t, cols, rows))))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
d('supervisor pane-log ≡ tmux pipe-pane', () => {
|
|
26
|
+
test('RAW-BYTE-IDENTITY: the same child yields byte-identical logs (LF/CRLF + multibyte + ANSI)', async () => {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'panelog-bp-'))
|
|
28
|
+
const tmuxSock = join(dir, 't.sock')
|
|
29
|
+
const logTmux = join(dir, 'tmux.log')
|
|
30
|
+
const logSup = join(dir, 'sup.log')
|
|
31
|
+
const runDir = join(dir, 'run')
|
|
32
|
+
// bare \n, \r\n, multibyte ❯ › … é (the latin1 grabli), ANSI SGR — the byte classes that diverge
|
|
33
|
+
const child = ['sh', '-c', `sleep 0.5; printf 'L1\\nL2\\r\\ng:❯›…é\\n\\033[31mRED\\033[0m\\nTAB\\tEND\\n'; sleep 1`]
|
|
34
|
+
try {
|
|
35
|
+
spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'p', '-x', '80', '-y', '24', ...child], { stdio: 'ignore' })
|
|
36
|
+
spawnSync('tmux', ['-S', tmuxSock, 'pipe-pane', '-o', '-t', 'p', `cat >> '${logTmux}'`], { stdio: 'ignore' })
|
|
37
|
+
startSupervisorDaemon({ session: 'bp', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
|
|
38
|
+
await sleep(2300) // both children: sleep .5 + emit + sleep 1 ≈ exit ~1.5s; tmux's cat EOF-flushes
|
|
39
|
+
const a = readBuf(logTmux)
|
|
40
|
+
const b = readBuf(logSup)
|
|
41
|
+
expect(b.length).toBeGreaterThan(0)
|
|
42
|
+
expect(b.equals(a)).toBe(true) // byte-for-byte — raw Buffer passthrough, shared onlcr default
|
|
43
|
+
expect(b.includes(Buffer.from('❯›…é', 'utf8'))).toBe(true) // multibyte survived (no latin1 re-encode)
|
|
44
|
+
} finally {
|
|
45
|
+
spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
|
|
46
|
+
try {
|
|
47
|
+
killSession(runDir, 'bp')
|
|
48
|
+
} catch {
|
|
49
|
+
/* */
|
|
50
|
+
}
|
|
51
|
+
await sleep(300)
|
|
52
|
+
rmSync(dir, { recursive: true, force: true })
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('BINDING: a model from the supervisor pane-log renders what tmux capture-pane shows', async () => {
|
|
57
|
+
const dir = mkdtempSync(join(tmpdir(), 'panelog-bind-'))
|
|
58
|
+
const tmuxSock = join(dir, 't.sock')
|
|
59
|
+
const logSup = join(dir, 'sup.log')
|
|
60
|
+
const runDir = join(dir, 'run')
|
|
61
|
+
const COLS = 80,
|
|
62
|
+
ROWS = 24
|
|
63
|
+
// a static frame (clear+home, three lines incl. the ❯ ready glyph), then idle so capture-pane sees it
|
|
64
|
+
const child = ['sh', '-c', `printf '\\033[2J\\033[HLine one\\r\\n ❯ ready prompt\\r\\nthird line ok\\r\\n'; sleep 30`]
|
|
65
|
+
try {
|
|
66
|
+
spawnSync('tmux', ['-S', tmuxSock, 'new-session', '-d', '-s', 'b', '-x', String(COLS), '-y', String(ROWS), ...child], { stdio: 'ignore' })
|
|
67
|
+
startSupervisorDaemon({ session: 'bind', runtime: 'tick', runDir, serve: { argv: child, env: { PATH: process.env.PATH ?? '' }, cwd: dir, paneLogPath: logSup } })
|
|
68
|
+
// let the frame render in both
|
|
69
|
+
let cap = ''
|
|
70
|
+
for (let i = 0; i < 30 && !/❯ ready prompt/.test(cap); i++) {
|
|
71
|
+
await sleep(200)
|
|
72
|
+
cap = spawnSync('tmux', ['-S', tmuxSock, 'capture-pane', '-p', '-t', 'b'], { encoding: 'utf8' }).stdout || ''
|
|
73
|
+
}
|
|
74
|
+
const modelSup = await modelOf(readBuf(logSup), COLS, ROWS)
|
|
75
|
+
// tmux capture-pane render (its own emulator) == @xterm model of the SUPERVISOR's pane-log
|
|
76
|
+
const norm = (s: string): string =>
|
|
77
|
+
s
|
|
78
|
+
.split('\n')
|
|
79
|
+
.map(l => l.replace(/\s+$/, ''))
|
|
80
|
+
.join('\n')
|
|
81
|
+
.replace(/\n+$/, '')
|
|
82
|
+
expect(norm(modelSup)).toBe(norm(cap))
|
|
83
|
+
expect(modelSup).toContain('❯ ready prompt') // the ready glyph survived into the model
|
|
84
|
+
} finally {
|
|
85
|
+
spawnSync('tmux', ['-S', tmuxSock, 'kill-server'], { stdio: 'ignore' })
|
|
86
|
+
try {
|
|
87
|
+
killSession(runDir, 'bind')
|
|
88
|
+
} catch {
|
|
89
|
+
/* */
|
|
90
|
+
}
|
|
91
|
+
await sleep(300)
|
|
92
|
+
rmSync(dir, { recursive: true, force: true })
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
})
|
package/src/supervisor/paths.ts
CHANGED
|
@@ -12,3 +12,12 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
|
12
12
|
export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
|
|
13
13
|
export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
|
|
14
14
|
export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
|
|
15
|
+
/** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
|
|
16
|
+
* Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
|
|
17
|
+
* the daemon CLI entry, removed on shutdown. Absent → a bare throwaway/tick session. */
|
|
18
|
+
export const servePath = (runDir: string, session: string): string => join(runDir, `${session}.serve.json`)
|
|
19
|
+
/** Attached-client marker (spawn-flip Ф0b-3 slice 3c): present iff ≥1 supervisor client is attached.
|
|
20
|
+
* The daemon maintains it on every client add/delete; the warm-deliver path (a DIFFERENT process)
|
|
21
|
+
* reads it to gate the hosted busy-composer hold — never paste over a HUMAN's unfinished input, but
|
|
22
|
+
* never hold for the AI's own composer output when no operator is attached. */
|
|
23
|
+
export const attachedPath = (runDir: string, session: string): string => join(runDir, `${session}.attached`)
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
2
6
|
import {
|
|
3
7
|
BackpressureWriter,
|
|
4
8
|
FRAME_DATA,
|
|
@@ -6,12 +10,15 @@ import {
|
|
|
6
10
|
TERM_RESET,
|
|
7
11
|
capabilityResponses,
|
|
8
12
|
frame,
|
|
13
|
+
keysToBytes,
|
|
9
14
|
makeFramer,
|
|
10
15
|
parseSize,
|
|
11
16
|
sizePayload,
|
|
12
17
|
stripQueries,
|
|
13
18
|
} from './protocol.ts'
|
|
14
19
|
|
|
20
|
+
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
21
|
+
|
|
15
22
|
describe('frame protocol', () => {
|
|
16
23
|
test('frame() lays out [type][len:u32be][payload]', () => {
|
|
17
24
|
const f = frame(FRAME_DATA, 'hi')
|
|
@@ -129,3 +136,87 @@ describe('BackpressureWriter (port-dep #2: the ~90 KB-over-8 KB truncation that
|
|
|
129
136
|
expect(w.isDead).toBe(true)
|
|
130
137
|
})
|
|
131
138
|
})
|
|
139
|
+
|
|
140
|
+
describe('keysToBytes (tmux send-keys vocabulary → pty bytes, the boot key-set)', () => {
|
|
141
|
+
test('Enter → CR (the trust/proceed dialogs)', () => {
|
|
142
|
+
expect(keysToBytes(['Enter'])).toEqual(Buffer.from('\r'))
|
|
143
|
+
})
|
|
144
|
+
test('codex update-decline [2,Enter] → "2" + CR', () => {
|
|
145
|
+
expect(keysToBytes(['2', 'Enter'])).toEqual(Buffer.from('2\r'))
|
|
146
|
+
})
|
|
147
|
+
test('codex hooks-review [Down,Enter] in NORMAL cursor mode → CSI-B + CR', () => {
|
|
148
|
+
expect(keysToBytes(['Down', 'Enter'])).toEqual(Buffer.from('\x1b[B\r'))
|
|
149
|
+
})
|
|
150
|
+
test('[Down,Enter] under DECCKM application-cursor mode → SS3-B + CR (the mode-sensitivity boris flagged)', () => {
|
|
151
|
+
expect(keysToBytes(['Down', 'Enter'], { appCursorKeys: true })).toEqual(Buffer.from('\x1bOB\r'))
|
|
152
|
+
})
|
|
153
|
+
test('claude resume-picker [Down] → cursor-down only', () => {
|
|
154
|
+
expect(keysToBytes(['Down'])).toEqual(Buffer.from('\x1b[B'))
|
|
155
|
+
})
|
|
156
|
+
test('literal mode -l sends the rest verbatim (executeControl /compact)', () => {
|
|
157
|
+
expect(keysToBytes(['-l', '/compact'])).toEqual(Buffer.from('/compact'))
|
|
158
|
+
})
|
|
159
|
+
test('Escape (interrupt) → ESC', () => {
|
|
160
|
+
expect(keysToBytes(['Escape'])).toEqual(Buffer.from('\x1b'))
|
|
161
|
+
})
|
|
162
|
+
test('an unknown token falls through to literal text', () => {
|
|
163
|
+
expect(keysToBytes(['hello'])).toEqual(Buffer.from('hello'))
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// boris's acceptance bar: the translator must be BYTE-IDENTICAL to real `tmux send-keys` for the
|
|
168
|
+
// key-set the adapters actually return — not a "looks right" table. We capture exactly what tmux
|
|
169
|
+
// delivers to a pane (raw mode bypasses the line discipline so no CR↔NL cooking) and compare.
|
|
170
|
+
// Guarded by tmuxAvailable (a clean CI runner has no tmux → these skip, per the live-tmux lesson).
|
|
171
|
+
function tmuxSendKeysBytes(keys: string[], appCursor: boolean, n: number): Buffer {
|
|
172
|
+
const dir = mkdtempSync(join(tmpdir(), 'pts-keys-'))
|
|
173
|
+
const sock = join(dir, 's.sock')
|
|
174
|
+
const out = join(dir, 'out.bin')
|
|
175
|
+
// The pane program: optionally emit DECCKM (CSI ? 1 h) as its OWN output so tmux tracks
|
|
176
|
+
// application-cursor mode; then raw mode (capture send-keys bytes verbatim); then read exactly n
|
|
177
|
+
// bytes and exit (head flushes on exit — a buffered cat would lose the tail on kill).
|
|
178
|
+
const pre = appCursor ? `printf '\\033[?1h'; ` : ''
|
|
179
|
+
const paneCmd = `${pre}stty raw -echo; head -c ${n} > '${out}'`
|
|
180
|
+
try {
|
|
181
|
+
const create = spawnSync(
|
|
182
|
+
'tmux',
|
|
183
|
+
['-S', sock, 'new-session', '-d', '-s', 'k', '-x', '80', '-y', '24', 'sh', '-c', paneCmd],
|
|
184
|
+
{ stdio: 'ignore' },
|
|
185
|
+
)
|
|
186
|
+
if (create.status !== 0) throw new Error('tmux new-session failed')
|
|
187
|
+
Bun.sleepSync(400) // let the pane reach raw mode + tmux process the DECCKM output
|
|
188
|
+
spawnSync('tmux', ['-S', sock, 'send-keys', '-t', 'k', ...keys], { stdio: 'ignore' })
|
|
189
|
+
const deadline = Date.now() + 3000
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
if (existsSync(out) && statSync(out).size >= n) break
|
|
192
|
+
Bun.sleepSync(50)
|
|
193
|
+
}
|
|
194
|
+
return existsSync(out) ? readFileSync(out) : Buffer.alloc(0)
|
|
195
|
+
} finally {
|
|
196
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
197
|
+
try {
|
|
198
|
+
rmSync(dir, { recursive: true, force: true })
|
|
199
|
+
} catch {
|
|
200
|
+
/* */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
describe.if(tmuxAvailable)('keysToBytes byte-identity vs real tmux send-keys (live)', () => {
|
|
206
|
+
test('NORMAL cursor mode: [Down,Enter]', () => {
|
|
207
|
+
const ours = keysToBytes(['Down', 'Enter'])
|
|
208
|
+
expect(tmuxSendKeysBytes(['Down', 'Enter'], false, ours.length)).toEqual(ours)
|
|
209
|
+
})
|
|
210
|
+
test('DECCKM application-cursor mode: [Down,Enter] → SS3', () => {
|
|
211
|
+
const ours = keysToBytes(['Down', 'Enter'], { appCursorKeys: true })
|
|
212
|
+
expect(tmuxSendKeysBytes(['Down', 'Enter'], true, ours.length)).toEqual(ours)
|
|
213
|
+
})
|
|
214
|
+
test('literal digit + Enter: [2,Enter]', () => {
|
|
215
|
+
const ours = keysToBytes(['2', 'Enter'])
|
|
216
|
+
expect(tmuxSendKeysBytes(['2', 'Enter'], false, ours.length)).toEqual(ours)
|
|
217
|
+
})
|
|
218
|
+
test('bare Enter', () => {
|
|
219
|
+
const ours = keysToBytes(['Enter'])
|
|
220
|
+
expect(tmuxSendKeysBytes(['Enter'], false, ours.length)).toEqual(ours)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
@@ -70,6 +70,55 @@ export function stripQueries(s: string): string {
|
|
|
70
70
|
return s.replace(QUERY_RE, '')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
|
|
74
|
+
// Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
|
|
75
|
+
// boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
|
|
76
|
+
// the MODEL exactly as the launch primitive answers them via `tmux send-keys`. PURE — the byte
|
|
77
|
+
// output is unit-comparable to real `tmux send-keys` (the acceptance bar: byte-identity, not a
|
|
78
|
+
// "looks right" table).
|
|
79
|
+
//
|
|
80
|
+
// tmux semantics mirrored for the vocabulary our RuntimeAdapters actually emit (bootDialogKeys /
|
|
81
|
+
// permissionDialogKeys / executeControl): a leading `-l` switches to LITERAL mode (every following
|
|
82
|
+
// token sent verbatim, no key-name lookup); otherwise each token is a KEY NAME when known, else
|
|
83
|
+
// literal text. Cursor keys depend on DECCKM application-cursor-keys mode — `\x1b[B` (normal) vs
|
|
84
|
+
// `\x1bOB` (application) — which tmux keys off the terminal mode and the caller reads from the
|
|
85
|
+
// @xterm model (term.modes.applicationCursorKeysMode), passing it in `enc`. Both codex hooks-review
|
|
86
|
+
// (`['Down','Enter']`) and the claude resume-picker (`['Down']`) put a cursor key in the boot set,
|
|
87
|
+
// so the mode is load-bearing — a hardcoded `\x1b[B` would corrupt the key under DECCKM.
|
|
88
|
+
|
|
89
|
+
export interface KeyEncoding {
|
|
90
|
+
/** DECCKM application-cursor-keys mode (CSI ? 1 h). When set, arrow keys use SS3 (ESC O x)
|
|
91
|
+
* instead of CSI (ESC [ x), matching what tmux send-keys emits in that mode. */
|
|
92
|
+
appCursorKeys?: boolean
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const NAMED_KEYS: Record<string, string> = {
|
|
96
|
+
Enter: '\r',
|
|
97
|
+
Escape: '\x1b',
|
|
98
|
+
Tab: '\t',
|
|
99
|
+
Space: ' ',
|
|
100
|
+
BSpace: '\x7f',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Translate `keys` (tmux send-keys vocabulary) to the pty byte stream. See section header. */
|
|
104
|
+
export function keysToBytes(keys: string[], enc: KeyEncoding = {}): Buffer {
|
|
105
|
+
const cur: Record<string, string> = enc.appCursorKeys
|
|
106
|
+
? { Up: '\x1bOA', Down: '\x1bOB', Right: '\x1bOC', Left: '\x1bOD' }
|
|
107
|
+
: { Up: '\x1b[A', Down: '\x1b[B', Right: '\x1b[C', Left: '\x1b[D' }
|
|
108
|
+
let literal = false
|
|
109
|
+
let out = ''
|
|
110
|
+
for (const k of keys) {
|
|
111
|
+
if (!literal && k === '-l') {
|
|
112
|
+
literal = true
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (!literal && k in NAMED_KEYS) out += NAMED_KEYS[k]
|
|
116
|
+
else if (!literal && k in cur) out += cur[k]
|
|
117
|
+
else out += k
|
|
118
|
+
}
|
|
119
|
+
return Buffer.from(out, 'utf8')
|
|
120
|
+
}
|
|
121
|
+
|
|
73
122
|
// ── port-dep #1: terminal reset on every client exit path ───────────────────────
|
|
74
123
|
// The child sets a STACK of app-modes on the live terminal (alt-screen, bracketed-paste,
|
|
75
124
|
// focus-events, theme-update, ALL mouse tracking, kitty-keyboard, scroll-region, hidden cursor).
|
|
@@ -144,6 +144,36 @@ describe('createComposerDeliveryQueue', () => {
|
|
|
144
144
|
expect(failed.join('\n')).toMatch(/vanished|replaced/)
|
|
145
145
|
})
|
|
146
146
|
|
|
147
|
+
test('async-correctness: concurrent enqueue with ASYNC predicates → each delivered exactly once, no dup/loss (Ф0b-3 3c-2)', async () => {
|
|
148
|
+
// The host-aware refactor makes shouldQueue/hasHumanInput/deliver awaitable. Inject ASYNC seams
|
|
149
|
+
// (a real yield window) and fire concurrent enqueues — the FIFO + per-identity drain guard must
|
|
150
|
+
// still deliver each envelope EXACTLY once (no race-doubled, no race-lost).
|
|
151
|
+
let held = true
|
|
152
|
+
const delivered: string[] = []
|
|
153
|
+
const q = createComposerDeliveryQueue({
|
|
154
|
+
pollMs: 5,
|
|
155
|
+
forceTimeoutMs: 2000,
|
|
156
|
+
shouldQueue: async () => { await sleep(4); return true },
|
|
157
|
+
hasHumanInput: async () => { await sleep(2); return held },
|
|
158
|
+
sessionToken: () => 'session-1:pane-1',
|
|
159
|
+
sessionAlive: () => true,
|
|
160
|
+
deliver: async (_target, envelope) => { await sleep(1); delivered.push(envelope); return ok(undefined) },
|
|
161
|
+
notifyFailed: () => { /* */ },
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const acks = await Promise.all([
|
|
165
|
+
q.tryEnqueue(args('msg-A')),
|
|
166
|
+
q.tryEnqueue(args('msg-B')),
|
|
167
|
+
q.tryEnqueue(args('msg-C')),
|
|
168
|
+
])
|
|
169
|
+
expect(acks.every(a => a?.ok)).toBe(true)
|
|
170
|
+
await sleep(40)
|
|
171
|
+
expect(delivered).toEqual([]) // all three held while busy
|
|
172
|
+
held = false
|
|
173
|
+
await sleep(80)
|
|
174
|
+
expect(delivered.slice().sort()).toEqual(['msg-A', 'msg-B', 'msg-C']) // each ONCE — no dup, no loss
|
|
175
|
+
})
|
|
176
|
+
|
|
147
177
|
test('daemon close/restart failAll re-fails every queued-but-undelivered envelope', async () => {
|
|
148
178
|
const delivered: string[] = []
|
|
149
179
|
const failed: string[] = []
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-2 slice 3 — host-aware in-session control (interrupt / compact).
|
|
2
|
+
//
|
|
3
|
+
// executeControlOnTarget becomes host-aware: a supervisor-HOSTED target has no `tmux send-keys`, so
|
|
4
|
+
// each control plan step is translated to pty bytes by keysToBytes (REUSED from the boot-driver) and
|
|
5
|
+
// sent over the supervisor socket. These suites cover the host branch hermetically (a seam injects the
|
|
6
|
+
// host-detect + the socket control-send) — asserting the BYTE TRANSLATION + dispatch. The live
|
|
7
|
+
// "interrupt really interrupts a hosted turn" effect is proven separately against a real hosted codex.
|
|
8
|
+
import { describe, expect, test } from 'bun:test'
|
|
9
|
+
import { executeControlOnTarget, type ControlHostSeam, type DeliveryTarget } from './index.ts'
|
|
10
|
+
|
|
11
|
+
const hostedCodex: DeliveryTarget = {
|
|
12
|
+
runtime: 'codex',
|
|
13
|
+
personality: 'ptyx',
|
|
14
|
+
address: 'codex-ptyx',
|
|
15
|
+
socketPath: '/tmp/nonexistent-iap-test.sock',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface Captured {
|
|
19
|
+
session: string
|
|
20
|
+
chunks: Buffer[]
|
|
21
|
+
stepDelayMs?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function capturingSeam(captured: Captured[]): ControlHostSeam {
|
|
25
|
+
return {
|
|
26
|
+
hostAlive: () => true,
|
|
27
|
+
sendControl: async (_runDir, session, chunks, opts) => {
|
|
28
|
+
captured.push({ session, chunks, stepDelayMs: opts.stepDelayMs })
|
|
29
|
+
return { ok: true }
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('executeControlOnTarget — hosted control over the socket (slice 3)', () => {
|
|
35
|
+
test('interrupt → a single bare ESC byte (\\x1b), no step delay', async () => {
|
|
36
|
+
const cap: Captured[] = []
|
|
37
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, capturingSeam(cap))
|
|
38
|
+
expect(r.ok).toBe(true)
|
|
39
|
+
expect(cap).toHaveLength(1)
|
|
40
|
+
expect(cap[0].session).toBe('codex-ptyx')
|
|
41
|
+
expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['\x1b'])
|
|
42
|
+
expect(cap[0].stepDelayMs).toBeUndefined()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('compact → "/compact" literal then Enter (CR), with the 300ms step delay', async () => {
|
|
46
|
+
const cap: Captured[] = []
|
|
47
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'compact' }, capturingSeam(cap))
|
|
48
|
+
expect(r.ok).toBe(true)
|
|
49
|
+
expect(cap[0].chunks.map(b => b.toString('latin1'))).toEqual(['/compact', '\r'])
|
|
50
|
+
expect(cap[0].stepDelayMs).toBe(300)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('flag-off (hostAlive=false) → the socket control-send is NEVER touched (tmux path)', async () => {
|
|
54
|
+
let sendCalls = 0
|
|
55
|
+
const seam: ControlHostSeam = {
|
|
56
|
+
hostAlive: () => false,
|
|
57
|
+
sendControl: async () => {
|
|
58
|
+
sendCalls++
|
|
59
|
+
return { ok: true }
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
// tmux send-keys will fail against the bogus socket — incidental; the contract is that flag-off
|
|
63
|
+
// never reaches the host branch.
|
|
64
|
+
await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
|
|
65
|
+
expect(sendCalls).toBe(0)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('socket control-send fails → loud error, no false ok', async () => {
|
|
69
|
+
const seam: ControlHostSeam = {
|
|
70
|
+
hostAlive: () => true,
|
|
71
|
+
sendControl: async () => ({ ok: false, error: 'socket dead during control send' }),
|
|
72
|
+
}
|
|
73
|
+
const r = await executeControlOnTarget(hostedCodex, { name: 'interrupt' }, seam)
|
|
74
|
+
expect(r.ok).toBe(false)
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
expect(r.error.message).toContain('hosted control "interrupt" failed')
|
|
77
|
+
expect(r.error.message).toContain('socket dead')
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('unsupported command for the runtime → explicit refusal (router runtime, no control plan)', async () => {
|
|
82
|
+
const router: DeliveryTarget = { runtime: 'telegram', personality: 'tg', address: 'telegram-tg', socketPath: '/tmp/x.sock' }
|
|
83
|
+
const r = await executeControlOnTarget(router, { name: 'interrupt' }, capturingSeam([]))
|
|
84
|
+
expect(r.ok).toBe(false)
|
|
85
|
+
if (!r.ok) expect(r.error.message).toContain('does not support control command')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Spawn-flip cutover Block 2, Ф0b-2 — host-aware warm delivery (`deliverWarm`).
|
|
2
|
+
//
|
|
3
|
+
// The warm-deliver path becomes host-aware: a supervisor-HOSTED target is delivered over its socket
|
|
4
|
+
// (Ф0a leaf) and CONFIRMED by the SAME final arbiter as the tmux busy path — a transcript-mtime
|
|
5
|
+
// advance, NOT the socket-ack. These suites cover the host branch hermetically (injected seams: a
|
|
6
|
+
// fake host-detect + host-deliver + a transcript-mtime proxy) so no live supervisor daemon is needed.
|
|
7
|
+
// The flag-off (tmux) routing is asserted by the host seam staying UNTOUCHED — the byte-identical
|
|
8
|
+
// guarantee that lets this ship dark.
|
|
9
|
+
import { afterEach, describe, expect, test } from 'bun:test'
|
|
10
|
+
import { deliverWarm, type DeliveryTarget, type WarmDeliverSeam } from './index.ts'
|
|
11
|
+
|
|
12
|
+
const hostedTarget: DeliveryTarget = {
|
|
13
|
+
runtime: 'codex',
|
|
14
|
+
personality: 'ptyx',
|
|
15
|
+
address: 'codex-ptyx',
|
|
16
|
+
socketPath: '/tmp/nonexistent-iap-test.sock',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const savedGrace = process.env.IAP_HOST_LIVENESS_GRACE_MS
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
if (savedGrace === undefined) delete process.env.IAP_HOST_LIVENESS_GRACE_MS
|
|
22
|
+
else process.env.IAP_HOST_LIVENESS_GRACE_MS = savedGrace
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('deliverWarm — flag-off (no live supervisor session) keeps the tmux path', () => {
|
|
26
|
+
test('hostAlive=false → the host-deliver seam is NEVER touched (routes to deliverViaTmux)', async () => {
|
|
27
|
+
let hostedCalls = 0
|
|
28
|
+
const seam: WarmDeliverSeam = {
|
|
29
|
+
hostAlive: () => false,
|
|
30
|
+
deliverHosted: async () => {
|
|
31
|
+
hostedCalls++
|
|
32
|
+
return { ok: true }
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
// The tmux path will fail against a bogus socket — but that is incidental; the contract under
|
|
36
|
+
// test is that flag-off NEVER reaches the host branch. We assert the host seam stayed cold.
|
|
37
|
+
await deliverWarm(hostedTarget, 'hi', '/tmp', seam)
|
|
38
|
+
expect(hostedCalls).toBe(0)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('deliverWarm — hosted target delivers over the socket + confirms by transcript advance', () => {
|
|
43
|
+
test('socket deliver ok AND transcript advances → ok', async () => {
|
|
44
|
+
let mtime = 100
|
|
45
|
+
const seam: WarmDeliverSeam = {
|
|
46
|
+
hostAlive: () => true,
|
|
47
|
+
deliverHosted: async () => {
|
|
48
|
+
mtime = 200 // the session took our message and started a model turn → transcript moved
|
|
49
|
+
return { ok: true }
|
|
50
|
+
},
|
|
51
|
+
newestActivityMtime: () => mtime,
|
|
52
|
+
}
|
|
53
|
+
const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
|
|
54
|
+
expect(r.ok).toBe(true)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('socket-ack is NOT landed: deliver ok but NO transcript advance → loud fail (treated as dead)', async () => {
|
|
58
|
+
process.env.IAP_HOST_LIVENESS_GRACE_MS = '0' // expire the HOST grace immediately — deterministic fail
|
|
59
|
+
let hostedCalls = 0
|
|
60
|
+
const seam: WarmDeliverSeam = {
|
|
61
|
+
hostAlive: () => true,
|
|
62
|
+
deliverHosted: async () => {
|
|
63
|
+
hostedCalls++
|
|
64
|
+
return { ok: true } // CR flushed to the socket — but the session never advanced
|
|
65
|
+
},
|
|
66
|
+
newestActivityMtime: () => 100, // constant → never advances past baseline
|
|
67
|
+
}
|
|
68
|
+
const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
|
|
69
|
+
expect(hostedCalls).toBe(1)
|
|
70
|
+
expect(r.ok).toBe(false)
|
|
71
|
+
if (!r.ok) expect(r.error.message).toContain('no transcript advance')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('socket deliver fails (socket dead/stalled) → loud fail, no false ok', async () => {
|
|
75
|
+
const seam: WarmDeliverSeam = {
|
|
76
|
+
hostAlive: () => true,
|
|
77
|
+
deliverHosted: async () => ({ ok: false, error: 'socket dead during submit' }),
|
|
78
|
+
newestActivityMtime: () => 100,
|
|
79
|
+
}
|
|
80
|
+
const r = await deliverWarm(hostedTarget, 'task', '/peer/cwd', seam)
|
|
81
|
+
expect(r.ok).toBe(false)
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
expect(r.error.message).toContain('deliver failed')
|
|
84
|
+
expect(r.error.message).toContain('socket dead')
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('no cwd (direct caller, no activity proxy) → confirmed-only (socket-ack), no mtime probe', async () => {
|
|
89
|
+
let mtimeProbes = 0
|
|
90
|
+
const seam: WarmDeliverSeam = {
|
|
91
|
+
hostAlive: () => true,
|
|
92
|
+
deliverHosted: async () => ({ ok: true }),
|
|
93
|
+
newestActivityMtime: () => {
|
|
94
|
+
mtimeProbes++
|
|
95
|
+
return 100
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
const r = await deliverWarm(hostedTarget, 'task', undefined, seam)
|
|
99
|
+
expect(r.ok).toBe(true)
|
|
100
|
+
expect(mtimeProbes).toBe(0) // no cwd → the transcript proxy is never consulted
|
|
101
|
+
})
|
|
102
|
+
})
|