@agfpd/iapeer 0.2.71 → 0.2.73
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/launch/bootdeliver.test.ts +6 -1
- package/src/launch/index.ts +23 -7
- package/src/launch/launchdRun.ts +20 -4
- package/src/launch/ptyHost.test.ts +15 -10
- package/src/launch/ptyHost.ts +10 -5
- package/src/launch/sockdir.test.ts +6 -1
- package/src/shadow/enumerate-skip.test.ts +16 -14
- package/src/shadow/index.ts +5 -3
- package/src/transport/hostDeliver.test.ts +35 -0
- package/src/transport/index.ts +7 -0
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// key-by-key retype), not the tty discipline.
|
|
13
13
|
|
|
14
14
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
15
|
-
import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
|
|
15
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
16
16
|
import { tmpdir } from 'os'
|
|
17
17
|
import { join } from 'path'
|
|
18
18
|
import { spawnSync } from 'child_process'
|
|
@@ -89,6 +89,11 @@ describe('boot first-message delivery (load-buffer + bracketed paste)', () => {
|
|
|
89
89
|
readyGateSecs: 8,
|
|
90
90
|
logDir: join(root, 'logs'),
|
|
91
91
|
}
|
|
92
|
+
// pty-supervisor hosting is the DEFAULT now; this fixture exercises the legacy TMUX boot/delivery
|
|
93
|
+
// path (load-buffer + bracketed paste) explicitly, so opt this peer OUT of pty with a .no-pty-host
|
|
94
|
+
// marker (otherwise launch hosts it under the supervisor and the tmux-path assertions never apply).
|
|
95
|
+
mkdirSync(cfg.logDir, { recursive: true })
|
|
96
|
+
writeFileSync(join(cfg.logDir, `${spec.identity}.no-pty-host`), '')
|
|
92
97
|
try {
|
|
93
98
|
const r = await launch(spec, catAdapter(recv), firstMessage, cfg)
|
|
94
99
|
expect(r.status).toBe('READY')
|
package/src/launch/index.ts
CHANGED
|
@@ -249,17 +249,22 @@ export const launch: LaunchFn = async (
|
|
|
249
249
|
// at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
|
|
250
250
|
mkdirSync(dirname(sock), { recursive: true })
|
|
251
251
|
|
|
252
|
-
// ──
|
|
253
|
-
//
|
|
252
|
+
// ── PTY-SUPERVISOR HOSTING — the DEFAULT (opt-out per-peer via `.no-pty-host`) ─────────────────────
|
|
253
|
+
// By default a tui peer is HOSTED by the supervisor (it owns the pty + boot-driver + pane-log) instead
|
|
254
254
|
// of `tmux new-session`. The supervisor serves the SAME launch-composed argv+env (buildLaunchInvocation,
|
|
255
255
|
// the single source the tmux path also uses), so the hosted session is parity-identical. Readiness is
|
|
256
256
|
// read off the pane-log model (the supervisor's boot-driver answers startup dialogs internally);
|
|
257
257
|
// delivery goes over the supervisor socket. UNCONDITIONAL SPAWN-FALLBACK: any host-bringup failure
|
|
258
|
-
// falls through to the tmux path below — a
|
|
259
|
-
// this whole block is skipped and the tmux path is byte-identical (golden)
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
258
|
+
// falls through to the tmux path below — a hosted peer is NEVER worse than tmux ON SPAWN. Opt-OUT
|
|
259
|
+
// (`.no-pty-host` marker) → this whole block is skipped and the tmux path is byte-identical (golden);
|
|
260
|
+
// it is the dev/debug escape valid only while tmux still exists. Routers (telegram/notifier) are ALSO
|
|
261
|
+
// hosted by default (cutover infra-track): launchd STILL owns their lifecycle via run-infra (H4) — the
|
|
262
|
+
// supervisor only provides the pty + delivery socket + pane-log; the router branch below skips every
|
|
263
|
+
// TUI phase (no boot dialogs — BOOT_ADAPTERS has no router entry so the supervisor's boot-driver is
|
|
264
|
+
// skipped; no transcript ready-gate — newestActivityMtime=null). @xterm stays out of THIS process
|
|
265
|
+
// (ptyHost: @xterm-free start + deliver + sessionAlive; readiness's @xterm import is dynamic and only
|
|
266
|
+
// loads here, default-on — and the router branch never reaches it).
|
|
267
|
+
if ((adapter.kind === 'tui' || adapter.kind === 'router') && ptyHostEnabled(cfg.logDir, identity)) {
|
|
263
268
|
const invocation = buildLaunchInvocation(spec, adapter, cfg)
|
|
264
269
|
mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
|
|
265
270
|
const paneLogPath = `${cfg.logDir}/${identity}.log`
|
|
@@ -272,6 +277,17 @@ export const launch: LaunchFn = async (
|
|
|
272
277
|
invocation,
|
|
273
278
|
})
|
|
274
279
|
if (host.ok) {
|
|
280
|
+
// ROUTER (telegram/notifier): no startup dialogs and no transcript. The session is "up" the
|
|
281
|
+
// instant the supervisor child is serving — symmetric to the tmux router path, where launch
|
|
282
|
+
// returns READY the moment `tmux new-session` succeeds and the always-on caller (runAlwaysOn)
|
|
283
|
+
// does its own BOOT_RECHECK to catch an immediate child-death. Always-on routers launch with an
|
|
284
|
+
// EMPTY firstMessage; deliveries arrive LATER over the socket (deliverHosted → child stdin → the
|
|
285
|
+
// router's <iap>-marker stdin reader). So confirm the hosted session is live and return ready —
|
|
286
|
+
// no waitHostReady (would load @xterm for nothing), no first-message deliver, no ready-gate.
|
|
287
|
+
if (adapter.kind === 'router') {
|
|
288
|
+
if (hostSessionAlive(identity)) return ready(identity)
|
|
289
|
+
return fail(identity, 'hosted router session not alive immediately after bringup')
|
|
290
|
+
}
|
|
275
291
|
// BOOT: the supervisor's boot-driver dismisses startup dialogs internally; wait for the input
|
|
276
292
|
// surface via the pane-log model, then deliver over the socket + ready-gate on a model turn —
|
|
277
293
|
// the SAME shape as the tmux boot/deliver/ready-gate below, with host equivalents.
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
|
23
23
|
import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
|
|
24
24
|
import { readPeerProfile } from '../identity/index.ts'
|
|
25
25
|
import { getAdapter, launch } from './index.ts'
|
|
26
|
+
import { hostSessionAlive, killPtyHost } from './ptyHost.ts'
|
|
26
27
|
import { dismissCanary, signalCanaryClean } from './canary.ts'
|
|
27
28
|
import type { LaunchConfig, LaunchSpec } from './types.ts'
|
|
28
29
|
|
|
@@ -115,6 +116,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
115
116
|
const sock = buildSocketPath(runtime, personality, sockDir)
|
|
116
117
|
const adapter = getAdapter(runtime)
|
|
117
118
|
|
|
119
|
+
// Host-aware liveness (cutover infra-track): a flag-on router is supervisor-HOSTED (no tmux session);
|
|
120
|
+
// a flag-off one is tmux. Probe BOTH backings by RUNTIME STATE (not the `.pty-host` marker), so this
|
|
121
|
+
// is robust to a spawn-FALLBACK — if startPtyHost failed and launch fell back to tmux, the marker is
|
|
122
|
+
// present yet the live session is tmux. The router is "up" iff EITHER backing is alive; on child
|
|
123
|
+
// death BOTH go false → block-watch exits → run-infra exits → launchd KeepAlive respawns (the SOLE
|
|
124
|
+
// respawn owner; the supervisor never self-respawns — H4 held).
|
|
125
|
+
const isAlive = (): boolean => hostSessionAlive(identity) || sessionAlive(sock, identity)
|
|
126
|
+
|
|
118
127
|
const cfg: LaunchConfig = {
|
|
119
128
|
claudeBin: env.CLAUDE_BIN ?? 'claude',
|
|
120
129
|
codexBin: env.CODEX_BIN ?? 'codex',
|
|
@@ -143,7 +152,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
143
152
|
const spec = buildAlwaysOnSpec(personality, runtime, cwd, sockDir)
|
|
144
153
|
|
|
145
154
|
// Idempotent: bring up only when not already live.
|
|
146
|
-
if (!
|
|
155
|
+
if (!isAlive()) {
|
|
147
156
|
const result = await launch(spec, adapter, '', cfg)
|
|
148
157
|
if (result.status !== 'READY') {
|
|
149
158
|
process.stderr.write(`launchdRun: launch FAILED for ${identity}: ${result.reason ?? 'unknown'}\n`)
|
|
@@ -155,7 +164,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
155
164
|
// (a diagnostic, throttled by the plist) instead of a clean exit that reads as a
|
|
156
165
|
// healthy run in the launchd logs.
|
|
157
166
|
await sleep(BOOT_RECHECK_MS)
|
|
158
|
-
if (!
|
|
167
|
+
if (!isAlive()) {
|
|
159
168
|
process.stderr.write(
|
|
160
169
|
`launchdRun: ${identity} session died immediately after launch — check ${cfg.notifierBin ?? `${runtime}-runtime`}\n`,
|
|
161
170
|
)
|
|
@@ -175,7 +184,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
175
184
|
}
|
|
176
185
|
process.on('SIGTERM', onSignal)
|
|
177
186
|
process.on('SIGINT', onSignal)
|
|
178
|
-
while (!stop &&
|
|
187
|
+
while (!stop && isAlive()) {
|
|
179
188
|
await new Promise<void>(resolve => {
|
|
180
189
|
const timer = setTimeout(resolve, WATCH_INTERVAL_MS)
|
|
181
190
|
interrupt = () => {
|
|
@@ -190,7 +199,14 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
190
199
|
// holding stale in-memory state (см. teardownAlwaysOnSession). A natural session
|
|
191
200
|
// death (stop=false) skips this: there is nothing to tear down, exit 0 →
|
|
192
201
|
// KeepAlive respawns a fresh bring-up.
|
|
193
|
-
|
|
202
|
+
// Signal-initiated stop (bootout / shutdown / kickstart -k) tears the session down WITH this watcher.
|
|
203
|
+
// A hosted router → kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans
|
|
204
|
+
// up); a tmux router → the tmux teardown. Pick by the live backing. A NATURAL death (stop=false) skips
|
|
205
|
+
// this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
|
|
206
|
+
if (stop) {
|
|
207
|
+
if (hostSessionAlive(identity)) killPtyHost(identity)
|
|
208
|
+
else teardownAlwaysOnSession(sock, identity)
|
|
209
|
+
}
|
|
194
210
|
return 0
|
|
195
211
|
}
|
|
196
212
|
|
|
@@ -5,27 +5,32 @@ import { join } from 'node:path'
|
|
|
5
5
|
import { ptyHostEnabled } from './ptyHost.ts'
|
|
6
6
|
import { readyGatePtyFlipEnabled } from './readyGateModel.ts'
|
|
7
7
|
|
|
8
|
-
describe('ptyHostEnabled — the
|
|
9
|
-
test('
|
|
8
|
+
describe('ptyHostEnabled — pty-hosting is the DEFAULT, .no-pty-host opts out', () => {
|
|
9
|
+
test('ON by default (no marker needed); OFF when the .no-pty-host opt-out marker exists; per-peer', () => {
|
|
10
10
|
const dir = mkdtempSync(join(tmpdir(), 'ptyhost-flag-'))
|
|
11
11
|
try {
|
|
12
|
+
expect(ptyHostEnabled(dir, 'codex-x')).toBe(true) // DEFAULT: hosted out of the box, no marker
|
|
13
|
+
writeFileSync(join(dir, 'codex-x.no-pty-host'), '') // explicit opt-OUT → tmux
|
|
12
14
|
expect(ptyHostEnabled(dir, 'codex-x')).toBe(false)
|
|
13
|
-
|
|
14
|
-
expect(ptyHostEnabled(dir, 'codex-x')).toBe(true)
|
|
15
|
-
expect(ptyHostEnabled(dir, 'codex-y')).toBe(false) // per-peer, not global
|
|
15
|
+
expect(ptyHostEnabled(dir, 'codex-y')).toBe(true) // opt-out is per-peer, not global — y stays default-on
|
|
16
16
|
} finally {
|
|
17
17
|
rmSync(dir, { recursive: true, force: true })
|
|
18
18
|
}
|
|
19
19
|
})
|
|
20
20
|
|
|
21
|
-
test('
|
|
21
|
+
test('legacy .pty-host opt-in marker is REDUNDANT (ignored); .pty-readygate is orthogonal; only .no-pty-host flips to tmux', () => {
|
|
22
22
|
const dir = mkdtempSync(join(tmpdir(), 'ptyhost-distinct-'))
|
|
23
23
|
try {
|
|
24
|
-
|
|
25
|
-
expect(readyGatePtyFlipEnabled(dir, 'codex-perplex')).toBe(true)
|
|
26
|
-
expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(false) // the .pty-host marker is separate
|
|
24
|
+
// legacy opt-in marker no longer decides anything — pty is the default with or without it
|
|
27
25
|
writeFileSync(join(dir, 'codex-perplex.pty-host'), '')
|
|
28
|
-
expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true)
|
|
26
|
+
expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true)
|
|
27
|
+
// the ready-gate viewport-source marker is a SEPARATE concern — it does not decide pty-hosting
|
|
28
|
+
writeFileSync(join(dir, 'codex-perplex.pty-readygate'), '')
|
|
29
|
+
expect(readyGatePtyFlipEnabled(dir, 'codex-perplex')).toBe(true)
|
|
30
|
+
expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true) // still hosted (default)
|
|
31
|
+
// only the explicit opt-out marker flips it back to tmux
|
|
32
|
+
writeFileSync(join(dir, 'codex-perplex.no-pty-host'), '')
|
|
33
|
+
expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(false)
|
|
29
34
|
} finally {
|
|
30
35
|
rmSync(dir, { recursive: true, force: true })
|
|
31
36
|
}
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -26,12 +26,17 @@ import type { RuntimeAdapter } from './types.ts'
|
|
|
26
26
|
export const HOST_COLS = 220
|
|
27
27
|
export const HOST_ROWS = 50
|
|
28
28
|
|
|
29
|
-
/** Per-peer
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
29
|
+
/** Per-peer pty-hosting decision. pty-supervisor hosting is the DEFAULT — a fresh peer is hosted under
|
|
30
|
+
* the supervisor (not `tmux new-session`) WITHOUT any marker (Артур: «pty из коробки»). A peer opts OUT
|
|
31
|
+
* to the legacy tmux path with a `<logDir>/<identity>.no-pty-host` marker (the dev/debug escape, valid
|
|
32
|
+
* only while tmux still exists). Read in TWO places, both of which want "is this peer pty-hosted": the
|
|
33
|
+
* spawn-gate (launch — host vs tmux) and the shadow observer (skip pty-hosted peers, they have no tmux
|
|
34
|
+
* to compare; an opt-OUT peer is tmux → compared). Legacy `.pty-host` opt-in markers are now REDUNDANT
|
|
35
|
+
* (pty is the default) and harmless — ignored here, so the 29 migrated peers keep hosting unchanged.
|
|
36
|
+
* The golden spawn-fallback (startPtyHost fail → tmux, in launch) is SEPARATE and unaffected: a
|
|
37
|
+
* pty-default peer whose host bring-up fails still falls through to tmux — never worse on spawn. */
|
|
33
38
|
export function ptyHostEnabled(logDir: string, identity: string): boolean {
|
|
34
|
-
return existsSync(`${logDir}/${identity}.pty-host`)
|
|
39
|
+
return !existsSync(`${logDir}/${identity}.no-pty-host`)
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// not-yet-created dir (prod sock=/tmp always exists; this bit a sandbox pilot).
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
7
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
7
|
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
|
8
8
|
import { tmpdir } from 'os'
|
|
9
9
|
import { dirname, join } from 'path'
|
|
10
10
|
import { spawnSync } from 'child_process'
|
|
@@ -51,6 +51,11 @@ describe('launch creates a missing socket dir', () => {
|
|
|
51
51
|
alwaysOn: true,
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
// pty-supervisor hosting is the DEFAULT; this regression tests the legacy TMUX new-session sock-dir
|
|
55
|
+
// mkdir, so opt this peer OUT of pty with a .no-pty-host marker (else it is pty-hosted and the tmux
|
|
56
|
+
// mkdir never runs).
|
|
57
|
+
mkdirSync(cfg.logDir, { recursive: true })
|
|
58
|
+
writeFileSync(join(cfg.logDir, `${spec.identity}.no-pty-host`), '')
|
|
54
59
|
try {
|
|
55
60
|
// The regression assertion: before the fix the dir was NOT created and
|
|
56
61
|
// `tmux new-session -S <sock>` failed silently; now launch mkdir's it. (We assert
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
|
|
2
2
|
//
|
|
3
|
-
// The observer compares the pty-model vs LIVE TMUX.
|
|
4
|
-
// compare against
|
|
5
|
-
// This proves the
|
|
6
|
-
// present): the
|
|
7
|
-
// CI runner has no tmux; the release machine has it —
|
|
3
|
+
// The observer compares the pty-model vs LIVE TMUX. pty-hosting is now the DEFAULT, so a normal
|
|
4
|
+
// (unmarked) peer has no tmux to compare against — and during a flip a lingering tmux socket for it
|
|
5
|
+
// would surface FALSE divergences. This proves the skip fires IN enumerate even in that race (a live
|
|
6
|
+
// tmux session + a pane-log present): the default peer is NOT enrolled, while a `.no-pty-host` opt-out
|
|
7
|
+
// (tmux) sibling IS. Real-tmux, guarded (a clean CI runner has no tmux; the release machine has it —
|
|
8
|
+
// same pattern as routesend-wake).
|
|
8
9
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
9
10
|
import { spawnSync } from 'node:child_process'
|
|
10
11
|
import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
@@ -17,8 +18,8 @@ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status ===
|
|
|
17
18
|
|
|
18
19
|
let sockDir: string
|
|
19
20
|
let logDir: string
|
|
20
|
-
const KEEP = 'keepobs' //
|
|
21
|
-
const SKIP = 'skipobs' //
|
|
21
|
+
const KEEP = 'keepobs' // .no-pty-host opt-out (tmux) → observed
|
|
22
|
+
const SKIP = 'skipobs' // default (no marker) → pty-hosted → skipped
|
|
22
23
|
const addrOf = (p: string) => `codex-${p}`
|
|
23
24
|
const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
|
|
24
25
|
|
|
@@ -43,9 +44,10 @@ beforeAll(() => {
|
|
|
43
44
|
startTmux(p)
|
|
44
45
|
writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
|
|
45
46
|
}
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
47
|
+
// KEEP carries the `.no-pty-host` opt-OUT → it is on tmux, so the observer MUST compare it. SKIP is a
|
|
48
|
+
// DEFAULT (unmarked) peer → pty-hosted → skipped even WITH a lingering live tmux session (the
|
|
49
|
+
// flip-transition race the skip guards against).
|
|
50
|
+
writeFileSync(join(logDir, `${addrOf(KEEP)}.no-pty-host`), '')
|
|
49
51
|
})
|
|
50
52
|
|
|
51
53
|
afterAll(() => {
|
|
@@ -54,12 +56,12 @@ afterAll(() => {
|
|
|
54
56
|
rmSync(logDir, { recursive: true, force: true })
|
|
55
57
|
})
|
|
56
58
|
|
|
57
|
-
describe.if(tmuxAvailable)('observer enumerate skips
|
|
58
|
-
test('a
|
|
59
|
+
describe.if(tmuxAvailable)('observer enumerate skips pty-default peers, compares .no-pty-host (tmux) peers', () => {
|
|
60
|
+
test('a default (unmarked, pty-hosted) peer is NOT enrolled despite a live tmux session; a .no-pty-host opt-out one IS', () => {
|
|
59
61
|
const models = new Map<string, any>()
|
|
60
62
|
enumerate(models, { sockDir, logDir, eventLogDir: logDir })
|
|
61
|
-
expect(models.has(addrOf(KEEP))).toBe(true) //
|
|
62
|
-
expect(models.has(addrOf(SKIP))).toBe(false) //
|
|
63
|
+
expect(models.has(addrOf(KEEP))).toBe(true) // .no-pty-host opt-out (tmux) → observed (live tmux + log)
|
|
64
|
+
expect(models.has(addrOf(SKIP))).toBe(false) // default pty-hosted → skipped despite lingering live tmux
|
|
63
65
|
for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
|
|
64
66
|
})
|
|
65
67
|
})
|
package/src/shadow/index.ts
CHANGED
|
@@ -61,9 +61,11 @@ export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker
|
|
|
61
61
|
for (const p of peers) {
|
|
62
62
|
const addr = buildProcessAddress(p.runtime, p.personality)
|
|
63
63
|
// SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
|
|
64
|
-
// LIVE TMUX
|
|
65
|
-
//
|
|
66
|
-
// (
|
|
64
|
+
// LIVE TMUX, so it only compares peers that are actually on tmux. pty-hosting is now the DEFAULT, so
|
|
65
|
+
// ptyHostEnabled is true for a normal peer (hosted → skip) and false only for an explicit `.no-pty-host`
|
|
66
|
+
// opt-OUT peer (tmux → compared). Skip BEFORE `live` — a peer that flips falls out of `live` and is
|
|
67
|
+
// reaped. (Post-migration the whole fleet is pty-default, so this skips everything and the observer is
|
|
68
|
+
// effectively retired until/unless a peer opts back to tmux.)
|
|
67
69
|
if (ptyHostEnabled(opts.logDir, addr)) continue
|
|
68
70
|
live.add(addr)
|
|
69
71
|
if (models.has(addr)) continue
|
|
@@ -100,3 +100,38 @@ describe('deliverWarm — hosted target delivers over the socket + confirms by t
|
|
|
100
100
|
expect(mtimeProbes).toBe(0) // no cwd → the transcript proxy is never consulted
|
|
101
101
|
})
|
|
102
102
|
})
|
|
103
|
+
|
|
104
|
+
// Cutover infra-track — a hosted ROUTER (telegram/notifier) has NO transcript proxy
|
|
105
|
+
// (adapter.newestActivityMtime=null), so the mtime-advance confirm a TUI uses can never be satisfied
|
|
106
|
+
// and would false-FAIL every router delivery. The host path confirms a router by the socket-ack —
|
|
107
|
+
// PARITY with deliverViaTmux's router C-j path (delivery-level confirm; router liveness is structural
|
|
108
|
+
// via launchd, not a model turn). These cases pin that behavior.
|
|
109
|
+
const routerTarget: DeliveryTarget = {
|
|
110
|
+
runtime: 'notifier', // notifierAdapter.kind === 'router'
|
|
111
|
+
personality: 'timer',
|
|
112
|
+
address: 'notifier-timer',
|
|
113
|
+
socketPath: '/tmp/nonexistent-iap-test.sock',
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe('deliverWarm — hosted ROUTER confirms by socket-ack (no transcript proxy)', () => {
|
|
117
|
+
test('router deliver ok → ok EVEN THOUGH mtime never advances (the exact scenario a TUI fails on)', async () => {
|
|
118
|
+
process.env.IAP_HOST_LIVENESS_GRACE_MS = '0' // a mtime-gated path would fail at once here
|
|
119
|
+
const seam: WarmDeliverSeam = {
|
|
120
|
+
hostAlive: () => true,
|
|
121
|
+
deliverHosted: async () => ({ ok: true }),
|
|
122
|
+
newestActivityMtime: () => 100, // constant — a router writes no transcript; a TUI here → loud fail
|
|
123
|
+
}
|
|
124
|
+
const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
|
|
125
|
+
expect(r.ok).toBe(true) // socket-ack IS the confirm for a router; the non-advancing transcript is irrelevant
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('router socket deliver fails (dead/stalled) → loud fail, no false ok', async () => {
|
|
129
|
+
const seam: WarmDeliverSeam = {
|
|
130
|
+
hostAlive: () => true,
|
|
131
|
+
deliverHosted: async () => ({ ok: false, error: 'socket dead during paste' }),
|
|
132
|
+
}
|
|
133
|
+
const r = await deliverWarm(routerTarget, '<iap>x</iap>', '/peer/cwd', seam)
|
|
134
|
+
expect(r.ok).toBe(false)
|
|
135
|
+
if (!r.ok) expect(r.error.message).toContain('deliver failed')
|
|
136
|
+
})
|
|
137
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -564,6 +564,13 @@ async function deliverViaHost(
|
|
|
564
564
|
`message NOT delivered`,
|
|
565
565
|
)
|
|
566
566
|
}
|
|
567
|
+
// Router (telegram/notifier): no transcript proxy (adapter.newestActivityMtime=null) → the
|
|
568
|
+
// mtime-advance confirm below can NEVER be satisfied and would false-FAIL every router delivery.
|
|
569
|
+
// Confirm by the socket-ack just obtained — PARITY with deliverViaTmux's router path (C-j success =
|
|
570
|
+
// delivery-level confirm; a router's liveness is structural via launchd KeepAlive, not a model
|
|
571
|
+
// turn). See deliverViaTmux router branch (paste-buffer -r + C-j, no transcript confirm) + its
|
|
572
|
+
// header note "router … liveness is structural".
|
|
573
|
+
if (adapter.kind === 'router') return ok(undefined)
|
|
567
574
|
// socket-ack ≠ landed. With no activity proxy (direct callers/tests without a cwd) we can only
|
|
568
575
|
// trust the flushed submit — confirmed-only, mirroring deliverViaTmux's legacy-caller path.
|
|
569
576
|
if (!cwd) return ok(undefined)
|