@agfpd/iapeer 0.2.66 → 0.2.68

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.66",
3
+ "version": "0.2.68",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -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
- const canaryState = ensureServerCanary({ identity: s.identity, sock, exitLogDir: cfg.eventLogDir, env })
1259
- if (canaryState !== 'already') {
1260
- trace({ identity: s.identity, action: 'canary', state: canaryState, origin: 'retrofit' })
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` })
@@ -76,13 +76,15 @@ export interface StartDaemonResult {
76
76
  * re-invoke the installed binary through its `supervisor` verb instead. The caller appends
77
77
  * `daemon <session> <runtime> --run-dir <dir>`, so runSupervisorCli is reached either way.
78
78
  */
79
- export function resolveDaemonSelfArgv(execPath: string, selfPath: string, selfOnDisk: boolean): string[] {
80
- return selfOnDisk ? [execPath, selfPath] : [execPath, 'supervisor']
81
- }
82
- const selfArgv = (): string[] => {
83
- const self = fileURLToPath(import.meta.url)
84
- return resolveDaemonSelfArgv(process.execPath, self, existsSync(self))
79
+ export function resolveDaemonSelfArgv(execPath: string, selfPath: string): string[] {
80
+ // A `bun build --compile` standalone embeds this module at a `/$bunfs/root/…` virtual path
81
+ // (verified: import.meta.url → file:///$bunfs/root/…). `existsSync` is NO discriminator — Bun
82
+ // intercepts bunfs reads so it is true for BOTH src and compiled. The `$bunfs` marker in the self
83
+ // path IS the discriminator: compiled → re-invoke the installed binary through its `supervisor` verb
84
+ // (the caller appends `daemon …`); source → a real on-disk file, run it directly via the runtime.
85
+ return selfPath.includes('$bunfs') ? [execPath, 'supervisor'] : [execPath, selfPath]
85
86
  }
87
+ const selfArgv = (): string[] => resolveDaemonSelfArgv(process.execPath, fileURLToPath(import.meta.url))
86
88
 
87
89
  /** Start a detached supervisor daemon and wait until its socket is live. Idempotent. */
88
90
  export function startSupervisorDaemon(opts: StartDaemonOptions): StartDaemonResult {
@@ -7,15 +7,16 @@ import { describe, expect, test } from 'bun:test'
7
7
  import { resolveDaemonSelfArgv } from './index.ts'
8
8
 
9
9
  describe('resolveDaemonSelfArgv', () => {
10
- test('source (self on disk) → re-run the module directly with the runtime (bun)', () => {
11
- expect(resolveDaemonSelfArgv('/opt/homebrew/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts', true))
12
- .toEqual(['/opt/homebrew/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts'])
10
+ // Paths are the EMPIRICALLY-verified shapes: bun → real /private/... file; compiled → /$bunfs/root/...
11
+ test('source (real on-disk module) → re-run the module directly via the runtime (bun)', () => {
12
+ expect(resolveDaemonSelfArgv('/Users/x/.bun/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts'))
13
+ .toEqual(['/Users/x/.bun/bin/bun', '/Users/x/iapeer/src/supervisor/index.ts'])
13
14
  })
14
15
 
15
- test('compiled standalone (self NOT on disk = embedded /$bunfs) → installed binary + `supervisor` verb', () => {
16
- expect(resolveDaemonSelfArgv('/Users/x/.local/bin/iapeer', '/$bunfs/root/src/supervisor/index.ts', false))
16
+ test('compiled standalone (embedded /$bunfs path) → installed binary + `supervisor` verb', () => {
17
+ expect(resolveDaemonSelfArgv('/Users/x/.local/bin/iapeer', '/$bunfs/root/src/supervisor/index.ts'))
17
18
  .toEqual(['/Users/x/.local/bin/iapeer', 'supervisor'])
18
19
  // The caller appends `daemon <session> <runtime> --run-dir <dir>` → `iapeer supervisor daemon …`,
19
- // which the CLI routes to runSupervisorCli('daemon', …). NOT a bare bunfs-path (→ usage).
20
+ // which the CLI routes to runSupervisorCli('daemon', …). NOT a bare bunfs-path (→ usage dump).
20
21
  })
21
22
  })
@@ -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
+ })
@@ -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
- return listSessions(sock).includes(address)
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
- let entries: string[]
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),