@agfpd/iapeer 0.2.77 → 0.2.78

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.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +2 -2
  3. package/package.json +21 -2
  4. package/src/cli/index.ts +47 -48
  5. package/src/connect/index.ts +4 -4
  6. package/src/core/constants.ts +3 -3
  7. package/src/core/normalize.ts +2 -2
  8. package/src/create/index.ts +1 -1
  9. package/src/daemon/index.ts +5 -5
  10. package/src/daemon/main.ts +9 -9
  11. package/src/enable/index.ts +19 -18
  12. package/src/enable/provisionCommand.ts +10 -10
  13. package/src/identity/index.ts +2 -2
  14. package/src/identity/profileStandard.ts +8 -8
  15. package/src/init/index.ts +10 -10
  16. package/src/install/index.ts +2 -2
  17. package/src/install/signing.ts +3 -4
  18. package/src/launch/adapters/claude.ts +7 -7
  19. package/src/launch/adapters/codex.ts +5 -6
  20. package/src/launch/canary.ts +8 -8
  21. package/src/launch/cmdlog.ts +3 -3
  22. package/src/launch/composeSystemPrompt.ts +2 -1
  23. package/src/launch/index.ts +5 -6
  24. package/src/launch/invocation.ts +1 -1
  25. package/src/launch/launchd.ts +14 -15
  26. package/src/launch/launchdRun.ts +6 -6
  27. package/src/launch/nativeMemory.ts +9 -9
  28. package/src/launch/ptyHost.ts +1 -1
  29. package/src/launch/readyGateModel.ts +3 -3
  30. package/src/launch/types.ts +7 -7
  31. package/src/lifecycle/eventlog.ts +2 -2
  32. package/src/lifecycle/index.ts +21 -21
  33. package/src/lifecycle/queue.ts +1 -1
  34. package/src/onboard/index.ts +19 -13
  35. package/src/onboard/memory.ts +5 -5
  36. package/src/onboard/steps.ts +8 -9
  37. package/src/provision/index.ts +2 -3
  38. package/src/runtime/deploy.ts +2 -2
  39. package/src/runtime/index.ts +4 -4
  40. package/src/shadow/gate.ts +6 -6
  41. package/src/shadow/index.ts +2 -2
  42. package/src/status/index.ts +11 -11
  43. package/src/storage/index.ts +1 -1
  44. package/src/supervisor/daemon.ts +14 -8
  45. package/src/transport/index.ts +11 -11
  46. package/src/update/index.ts +4 -4
  47. package/src/cli/cli.test.ts +0 -603
  48. package/src/cli/listTui.test.ts +0 -71
  49. package/src/codec/codec.test.ts +0 -271
  50. package/src/connect/connect.test.ts +0 -163
  51. package/src/core/constants.test.ts +0 -32
  52. package/src/core/normalize.test.ts +0 -98
  53. package/src/create/create.test.ts +0 -143
  54. package/src/daemon/daemon-http.test.ts +0 -114
  55. package/src/daemon/daemon.test.ts +0 -267
  56. package/src/daemon/main.test.ts +0 -413
  57. package/src/enable/enable.test.ts +0 -95
  58. package/src/enable/provisionCommand.test.ts +0 -107
  59. package/src/identity/identity.test.ts +0 -279
  60. package/src/identity/profileStandard.test.ts +0 -204
  61. package/src/init/init.test.ts +0 -215
  62. package/src/init/runtime-resolve.test.ts +0 -49
  63. package/src/install/install.test.ts +0 -66
  64. package/src/install/signing.test.ts +0 -84
  65. package/src/launch/bootdeliver.test.ts +0 -108
  66. package/src/launch/bootstrap.test.ts +0 -56
  67. package/src/launch/canary.test.ts +0 -346
  68. package/src/launch/cmdlog.test.ts +0 -94
  69. package/src/launch/codexHooksTrust.test.ts +0 -248
  70. package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
  71. package/src/launch/composeSystemPrompt.test.ts +0 -145
  72. package/src/launch/hostComposerOccupied.test.ts +0 -63
  73. package/src/launch/invocation.test.ts +0 -174
  74. package/src/launch/launch.test.ts +0 -359
  75. package/src/launch/launchd.test.ts +0 -499
  76. package/src/launch/nativeMemory.test.ts +0 -193
  77. package/src/launch/ptyHost.test.ts +0 -38
  78. package/src/launch/readyGateModel.test.ts +0 -72
  79. package/src/launch/sockdir.test.ts +0 -74
  80. package/src/lifecycle/eventlog.test.ts +0 -114
  81. package/src/lifecycle/lifecycle.test.ts +0 -1056
  82. package/src/lifecycle/queue.test.ts +0 -185
  83. package/src/onboard/memory.test.ts +0 -157
  84. package/src/onboard/onboard.test.ts +0 -39
  85. package/src/onboard/steps.test.ts +0 -201
  86. package/src/provision/provision.test.ts +0 -288
  87. package/src/registry/registry.test.ts +0 -432
  88. package/src/runtime/runtime.test.ts +0 -226
  89. package/src/runtime/update.test.ts +0 -192
  90. package/src/shadow/enumerate-skip.test.ts +0 -67
  91. package/src/shadow/gate.test.ts +0 -286
  92. package/src/shadow/install.test.ts +0 -153
  93. package/src/shadow/shadow.test.ts +0 -42
  94. package/src/status/status.test.ts +0 -208
  95. package/src/storage/peers-home.test.ts +0 -34
  96. package/src/storage/rotatelog.test.ts +0 -44
  97. package/src/storage/storage.test.ts +0 -65
  98. package/src/supervisor/attached-marker.test.ts +0 -84
  99. package/src/supervisor/boot.test.ts +0 -66
  100. package/src/supervisor/client.attach.test.ts +0 -107
  101. package/src/supervisor/daemon.test.ts +0 -213
  102. package/src/supervisor/deliver.test.ts +0 -58
  103. package/src/supervisor/panelog.test.ts +0 -95
  104. package/src/supervisor/protocol.test.ts +0 -222
  105. package/src/supervisor/selfargv.test.ts +0 -22
  106. package/src/supervisor/spinner-strip.test.ts +0 -52
  107. package/src/transport/compactDone.test.ts +0 -144
  108. package/src/transport/composerQueue.test.ts +0 -204
  109. package/src/transport/hostControl.test.ts +0 -87
  110. package/src/transport/hostDeliver.test.ts +0 -137
  111. package/src/transport/hostLive.test.ts +0 -50
  112. package/src/transport/routesend-wake.test.ts +0 -357
  113. package/src/update/update.test.ts +0 -208
@@ -1,1056 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
3
- import { tmpdir } from 'os'
4
- import { join } from 'path'
5
- import {
6
- attachPeer,
7
- classifyGoneSession,
8
- clearEphemeralArmed,
9
- clearNewEager,
10
- clearStopped,
11
- composeFirstMessage,
12
- countRecentDeaths,
13
- folderLaunch,
14
- hasEphemeralArmed,
15
- hasIdleReaped,
16
- hasNewEager,
17
- isEphemeralPeer,
18
- isLaunchdManaged,
19
- isStopped,
20
- lastActiveRuntime,
21
- loadLifecycleConfig,
22
- readDeaths,
23
- readTopic,
24
- recordDeath,
25
- resolveWakeMode,
26
- resolveWakeRuntime,
27
- setEphemeralArmed,
28
- setIdleReaped,
29
- setNewEager,
30
- setStopped,
31
- superviseTick,
32
- wakeOrSpawn,
33
- withWakeLock,
34
- writeTopic,
35
- type LifecycleConfig,
36
- } from './index.ts'
37
- import { spawnSync } from 'child_process'
38
- import { upsertPeer, type PeerRecord } from '../registry/index.ts'
39
-
40
- function peer(over: Partial<PeerRecord>): PeerRecord {
41
- return {
42
- personality: 'p',
43
- runtime: 'claude',
44
- runtimes: ['claude'],
45
- description: '',
46
- intelligence: 'artificial',
47
- cwd: '/tmp/p',
48
- ...over,
49
- }
50
- }
51
-
52
- // ─────────────────────────────────────────────────────────────────────────────
53
- // H5 — resolveWakeRuntime (registry-based, no live-socket scan)
54
- // ─────────────────────────────────────────────────────────────────────────────
55
-
56
- describe('resolveWakeRuntime (H5)', () => {
57
- test('explicit declared caller runtime wins', () => {
58
- const r = resolveWakeRuntime('codex', peer({ runtimes: ['claude', 'codex'] }))
59
- expect(r.ok && r.value).toBe('codex')
60
- })
61
-
62
- test('explicit UNDECLARED caller runtime → fail-loud (no silent claude)', () => {
63
- const r = resolveWakeRuntime('codex', peer({ runtime: 'claude', runtimes: ['claude'] }))
64
- expect(r.ok).toBe(false)
65
- })
66
-
67
- test('no caller runtime → peer.runtime (registry default)', () => {
68
- const r = resolveWakeRuntime(undefined, peer({ runtime: 'codex', runtimes: ['codex', 'claude'] }))
69
- expect(r.ok && r.value).toBe('codex')
70
- })
71
-
72
- test('no caller runtime, no peer.runtime → first of runtimes[]', () => {
73
- const r = resolveWakeRuntime(undefined, peer({ runtime: '' as never, runtimes: ['codex'] }))
74
- expect(r.ok && r.value).toBe('codex')
75
- })
76
-
77
- test('nothing to pick → fail-loud', () => {
78
- const r = resolveWakeRuntime(undefined, peer({ runtime: '' as never, runtimes: [] }))
79
- expect(r.ok).toBe(false)
80
- })
81
- })
82
-
83
- // ─────────────────────────────────────────────────────────────────────────────
84
- // H4 — isLaunchdManaged on the LIVE fleet (read-only)
85
- // ─────────────────────────────────────────────────────────────────────────────
86
-
87
- describe('isLaunchdManaged (H4 detector)', () => {
88
- // HERMETIC: point the detector at a TEMP LaunchAgents dir (IAPEER_LAUNCHAGENTS_DIR),
89
- // never the live ~/Library/LaunchAgents — so the test fires identically on CI and any
90
- // host, not just one where the timer peer happens to be installed.
91
- let laDir: string
92
- beforeEach(() => {
93
- laDir = mkdtempSync(join(tmpdir(), 'iapeer-h4-'))
94
- })
95
- afterEach(() => {
96
- rmSync(laDir, { recursive: true, force: true })
97
- })
98
-
99
- test('a com.iapeer.<p>.plist present in the LaunchAgents dir → true (read-only managed)', () => {
100
- writeFileSync(join(laDir, 'com.iapeer.timer.plist'), '')
101
- expect(isLaunchdManaged('timer', { IAPEER_LAUNCHAGENTS_DIR: laDir } as NodeJS.ProcessEnv)).toBe(true)
102
- })
103
-
104
- test('no plist in the dir → false (daemon-owned, not launchd-managed)', () => {
105
- expect(isLaunchdManaged('iapeer-throwaway-no-plist-xyz', { IAPEER_LAUNCHAGENTS_DIR: laDir } as NodeJS.ProcessEnv)).toBe(false)
106
- })
107
- })
108
-
109
- // ─────────────────────────────────────────────────────────────────────────────
110
- // withWakeLock — serializes per identity (concurrent = one at a time)
111
- // ─────────────────────────────────────────────────────────────────────────────
112
-
113
- describe('withWakeLock', () => {
114
- let stateDir: string
115
- beforeEach(() => {
116
- stateDir = mkdtempSync(join(tmpdir(), 'iapeer-wakelock-'))
117
- })
118
- afterEach(() => {
119
- rmSync(stateDir, { recursive: true, force: true })
120
- })
121
-
122
- const cfg = (): LifecycleConfig =>
123
- ({
124
- claudeBin: 'claude',
125
- codexBin: 'codex',
126
- sockDir: '/tmp',
127
- stateDir,
128
- logDir: stateDir,
129
- bootDeadlineSecs: 1,
130
- readyGateSecs: 1,
131
- idleSecs: 1,
132
- }) as LifecycleConfig
133
-
134
- test('two concurrent locks on the same identity run strictly serialized', async () => {
135
- const order: string[] = []
136
- const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
137
- const p1 = withWakeLock(cfg(), 'claude-x', async () => {
138
- order.push('1-start')
139
- await sleep(150)
140
- order.push('1-end')
141
- })
142
- // ensure p1 grabs the lock first
143
- await sleep(20)
144
- const p2 = withWakeLock(cfg(), 'claude-x', async () => {
145
- order.push('2-start')
146
- order.push('2-end')
147
- })
148
- await Promise.all([p1, p2])
149
- expect(order).toEqual(['1-start', '1-end', '2-start', '2-end'])
150
- })
151
-
152
- test('locks on DIFFERENT identities do not block each other', async () => {
153
- const order: string[] = []
154
- const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
155
- const a = withWakeLock(cfg(), 'claude-a', async () => {
156
- order.push('a-start')
157
- await sleep(120)
158
- order.push('a-end')
159
- })
160
- await sleep(20)
161
- const b = withWakeLock(cfg(), 'claude-b', async () => {
162
- order.push('b-start') // must interleave — different lock
163
- })
164
- await Promise.all([a, b])
165
- expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('a-end'))
166
- })
167
- })
168
-
169
- // ─────────────────────────────────────────────────────────────────────────────
170
- // superviseTick — H4 guard FIRST (safe: temp LaunchAgents dir, no real fleet)
171
- // ─────────────────────────────────────────────────────────────────────────────
172
-
173
- describe('superviseTick H4 guard', () => {
174
- let stateDir: string
175
- let laDir: string
176
- beforeEach(() => {
177
- stateDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-state-'))
178
- laDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-la-'))
179
- })
180
- afterEach(() => {
181
- rmSync(stateDir, { recursive: true, force: true })
182
- rmSync(laDir, { recursive: true, force: true })
183
- })
184
-
185
- const cfg = (): LifecycleConfig =>
186
- ({
187
- claudeBin: 'claude',
188
- codexBin: 'codex',
189
- sockDir: '/tmp',
190
- stateDir,
191
- logDir: stateDir,
192
- eventLogDir: stateDir, // isolate the decision log into the temp dir (no real-root leak)
193
- bootDeadlineSecs: 1,
194
- readyGateSecs: 1,
195
- idleSecs: 1,
196
- }) as LifecycleConfig
197
-
198
- function writeState(personality: string): string {
199
- const identity = `claude-${personality}`
200
- writeFileSync(
201
- join(stateDir, `${identity}.session`),
202
- JSON.stringify({ identity, runtime: 'claude', personality, cwd: '/tmp/none', wokeAt: 0 }),
203
- )
204
- return identity
205
- }
206
- const env = () => ({ ...process.env, IAPEER_LAUNCHAGENTS_DIR: laDir })
207
-
208
- test('a launchd-managed peer (plist present) is SKIPPED first — never reaped', () => {
209
- // a fake plist in the TEMP LaunchAgents dir (real ~/Library is untouched)
210
- writeFileSync(join(laDir, 'com.iapeer.iapeer-fakelaunchd.plist'), '')
211
- const id = writeState('iapeer-fakelaunchd')
212
- const out = superviseTick(cfg(), { env: env(), nowMs: Date.now() })
213
- const o = out.find(x => x.identity === id)
214
- expect(o?.action).toBe('skipped-launchd')
215
- // read-only: its session-state is NOT removed
216
- expect(existsSync(join(stateDir, `${id}.session`))).toBe(true)
217
- })
218
-
219
- test('a STOPPED peer with a dead session → skipped-stopped: no death record, park marker ensured', () => {
220
- // boris repro (10.06): `iapeer stop` → next tick used to tag reaped-gone
221
- // death=server-dead + recordDeath → post-start wake came up FRESH. A deliberate
222
- // stop is a clean park the daemon knows 100% — never a death.
223
- const c = cfg()
224
- const id = writeState('iapeer-supstopped')
225
- setStopped(c, id)
226
- const out = superviseTick(c, { env: env(), nowMs: Date.now() })
227
- const o = out.find(x => x.identity === id)
228
- expect(o?.action).toBe('skipped-stopped')
229
- expect(existsSync(join(stateDir, `${id}.session`))).toBe(false) // state dropped quietly
230
- expect(readDeaths(c, id).length).toBe(0) // NOT a death — crash-loop ring untouched
231
- expect(hasIdleReaped(c, id)).toBe(true) // clean park → post-start wake resumes
232
- const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
233
- expect(logged).toContain(`identity=${id} action=skipped-stopped`)
234
- expect(logged).not.toContain('death=') // no death class for a deliberate stop
235
- })
236
-
237
- test('a no-plist peer with a dead session → reaped-gone, state removed', () => {
238
- const c = cfg()
239
- const id = writeState('iapeer-supgone') // no plist, no live tmux session
240
- const out = superviseTick(c, { env: env(), nowMs: Date.now() })
241
- const o = out.find(x => x.identity === id)
242
- expect(o?.action).toBe('reaped-gone')
243
- expect(existsSync(join(stateDir, `${id}.session`))).toBe(false)
244
- // the decision leaves a DURABLE trace line (the observability contract) — and it
245
- // lands in the SANDBOXED eventLogDir, never the real ~/.iapeer.
246
- const logged = readFileSync(join(c.eventLogDir, 'lifecycle.log'), 'utf8')
247
- expect(logged).toContain(`ev=supervise identity=${id} action=reaped-gone`)
248
- expect(logged).toContain('outcome=fresh-next-msg')
249
- // death-class tag: no socket file at all in this sandbox → the server is gone
250
- expect(logged).toContain('death=server-dead')
251
- })
252
-
253
- test('empty state dir → no outcomes', () => {
254
- expect(superviseTick(cfg(), { env: env() })).toEqual([])
255
- })
256
- })
257
-
258
- // ─────────────────────────────────────────────────────────────────────────────
259
- // wakeOrSpawn H4 — REFUSES to wake a launchd-managed peer (no spawn at all)
260
- // ─────────────────────────────────────────────────────────────────────────────
261
-
262
- describe('wakeOrSpawn H4 refusal', () => {
263
- test('a launchd-managed peer is NOT woken (returns FAILED before any spawn)', async () => {
264
- const root = mkdtempSync(join(tmpdir(), 'iapeer-h4-root-'))
265
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-h4-la-'))
266
- try {
267
- // a registered peer that ALSO has a (fake) launchd plist → launchd domain
268
- writeFileSync(join(laDir, 'com.iapeer.iapeer-h4ld.plist'), '')
269
- await upsertPeer(
270
- { personality: 'iapeer-h4ld', runtime: 'claude', cwd: '/tmp/none', intelligence: 'artificial' },
271
- { rootDir: root },
272
- )
273
- const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
274
- const r = await wakeOrSpawn({ personality: 'iapeer-h4ld', runtime: 'claude', task: 'must not spawn' }, { env })
275
- expect(r.status).toBe('FAILED')
276
- expect(r.woke).toBe(false)
277
- expect(r.reason).toMatch(/launchd-managed/)
278
- } finally {
279
- rmSync(root, { recursive: true, force: true })
280
- rmSync(laDir, { recursive: true, force: true })
281
- }
282
- })
283
- })
284
-
285
- // ─────────────────────────────────────────────────────────────────────────────
286
- // wakeOrSpawn live-session fast path — marks the caller's task as NOT delivered
287
- // (the concurrent-sender envelope-loss seam: routeSend redelivers on this flag)
288
- // ─────────────────────────────────────────────────────────────────────────────
289
-
290
- describe('wakeOrSpawn live-session fast path', () => {
291
- // Live tmux required (spawns a real session). Skipped where tmux is absent — a
292
- // clean CI runner has none; the foundation's preversion gate runs it locally
293
- // (this machine has tmux), so the release path keeps the coverage.
294
- const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
295
- test.if(tmuxAvailable)('READY with taskDelivered:false — the winning wake delivered only ITS envelope', async () => {
296
- const root = mkdtempSync(join(tmpdir(), 'iapeer-fpl-root-'))
297
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-fpl-la-')) // empty → not launchd-managed
298
- const sockDir = join(root, 'socks')
299
- const sock = join(sockDir, 'tmux-iap-claude-fpl.sock')
300
- try {
301
- mkdirSync(sockDir, { recursive: true })
302
- await upsertPeer(
303
- { personality: 'fpl', runtime: 'claude', cwd: root, intelligence: 'artificial' },
304
- { rootDir: root },
305
- )
306
- // a live session already up — what a WINNING concurrent wake leaves behind
307
- const up = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'claude-fpl', 'cat'], { encoding: 'utf8' })
308
- expect(up.status).toBe(0)
309
- const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
310
- const r = await wakeOrSpawn({ personality: 'fpl', runtime: 'claude', task: 'second sender envelope' }, { env })
311
- expect(r.status).toBe('READY')
312
- expect(r.woke).toBe(false)
313
- // the contract under test: the fast path NEVER delivers the caller's task
314
- expect(r.taskDelivered).toBe(false)
315
- } finally {
316
- spawnSync('tmux', ['-S', sock, 'kill-server'], { encoding: 'utf8' })
317
- rmSync(root, { recursive: true, force: true })
318
- rmSync(laDir, { recursive: true, force: true })
319
- }
320
- })
321
- })
322
-
323
- // ─────────────────────────────────────────────────────────────────────────────
324
- // C1 — durable stopped flag (stop/start; daemon refuses to wake a stopped peer)
325
- // ─────────────────────────────────────────────────────────────────────────────
326
-
327
- describe('C1 durable stopped flag', () => {
328
- test('isStopped/setStopped/clearStopped round-trip', () => {
329
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-stopped-'))
330
- const cfg = { stateDir } as LifecycleConfig
331
- try {
332
- expect(isStopped(cfg, 'claude-x')).toBe(false)
333
- setStopped(cfg, 'claude-x')
334
- expect(isStopped(cfg, 'claude-x')).toBe(true)
335
- clearStopped(cfg, 'claude-x')
336
- expect(isStopped(cfg, 'claude-x')).toBe(false)
337
- } finally {
338
- rmSync(stateDir, { recursive: true, force: true })
339
- }
340
- })
341
-
342
- test('wakeOrSpawn REFUSES a stopped peer (FAILED stopped:true, before any spawn)', async () => {
343
- const root = mkdtempSync(join(tmpdir(), 'iapeer-stp-root-'))
344
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-stp-la-')) // empty → not launchd-managed
345
- try {
346
- await upsertPeer(
347
- { personality: 'stp', runtime: 'claude', cwd: '/tmp/none', intelligence: 'artificial' },
348
- { rootDir: root },
349
- )
350
- const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir }
351
- const cfg = loadLifecycleConfig(env)
352
- setStopped(cfg, 'claude-stp')
353
- const r = await wakeOrSpawn({ personality: 'stp', runtime: 'claude', task: 'must not wake' }, { env })
354
- expect(r.status).toBe('FAILED')
355
- expect(r.stopped).toBe(true)
356
- expect(r.reason).toMatch(/stopped/)
357
- // cleared → wakeable again (it will then FAIL later for the missing cwd, NOT stopped)
358
- clearStopped(cfg, 'claude-stp')
359
- const r2 = await wakeOrSpawn({ personality: 'stp', runtime: 'claude', task: 'x' }, { env })
360
- expect(r2.stopped).toBeFalsy()
361
- } finally {
362
- rmSync(root, { recursive: true, force: true })
363
- rmSync(laDir, { recursive: true, force: true })
364
- }
365
- })
366
- })
367
-
368
- // ─────────────────────────────────────────────────────────────────────────────
369
- // C2 — initial_prompt launch-seed (composeFirstMessage)
370
- // ─────────────────────────────────────────────────────────────────────────────
371
-
372
- describe('C2 initial_prompt (composeFirstMessage)', () => {
373
- function withProfile(initial_prompt?: string): string {
374
- const cwd = mkdtempSync(join(tmpdir(), 'iapeer-seed-'))
375
- mkdirSync(join(cwd, '.iapeer'), { recursive: true })
376
- writeFileSync(
377
- join(cwd, '.iapeer', 'peer-profile.json'),
378
- JSON.stringify({
379
- personality: 'p',
380
- runtime: 'claude',
381
- runtimes: ['claude'],
382
- intelligence: 'artificial',
383
- ...(initial_prompt ? { initial_prompt } : {}),
384
- }),
385
- )
386
- return cwd
387
- }
388
-
389
- test('fresh wake + initial_prompt → seed THEN task (both, seed first)', () => {
390
- const cwd = withProfile('First, read STATE.md.')
391
- try {
392
- expect(composeFirstMessage(cwd, '<iap>msg</iap>', true)).toBe('First, read STATE.md.\n\n<iap>msg</iap>')
393
- } finally {
394
- rmSync(cwd, { recursive: true, force: true })
395
- }
396
- })
397
-
398
- test('resume (fresh=false) → task only, no seed', () => {
399
- const cwd = withProfile('First, read STATE.md.')
400
- try {
401
- expect(composeFirstMessage(cwd, 'TASK', false)).toBe('TASK')
402
- } finally {
403
- rmSync(cwd, { recursive: true, force: true })
404
- }
405
- })
406
-
407
- test('fresh wake, NO initial_prompt → task only', () => {
408
- const cwd = withProfile(undefined)
409
- try {
410
- expect(composeFirstMessage(cwd, 'TASK', true)).toBe('TASK')
411
- } finally {
412
- rmSync(cwd, { recursive: true, force: true })
413
- }
414
- })
415
-
416
- test('fresh with seed but EMPTY task (eager /new) → seed alone (no trailing)', () => {
417
- const cwd = withProfile('Report you are up.')
418
- try {
419
- expect(composeFirstMessage(cwd, '', true)).toBe('Report you are up.')
420
- } finally {
421
- rmSync(cwd, { recursive: true, force: true })
422
- }
423
- })
424
- })
425
-
426
- // ─────────────────────────────────────────────────────────────────────────────
427
- // resolveWakeMode — TARGET redesign (daemon decides fresh-vs-resume by DEATH CAUSE
428
- // = .idle-reaped marker, plus peer-type/topic; NO agent-dropped fresh mark).
429
- // ─────────────────────────────────────────────────────────────────────────────
430
-
431
- /** A temp cwd with a peer-profile; interfaces.telegram present → human-conversational;
432
- * ephemeral → wake_policy "ephemeral". */
433
- function profileCwd(human: boolean, ephemeral = false): string {
434
- const cwd = mkdtempSync(join(tmpdir(), 'iapeer-wm-cwd-'))
435
- mkdirSync(join(cwd, '.iapeer'), { recursive: true })
436
- writeFileSync(
437
- join(cwd, '.iapeer', 'peer-profile.json'),
438
- JSON.stringify({
439
- personality: 'p',
440
- runtime: 'claude',
441
- runtimes: ['claude'],
442
- intelligence: human ? 'natural' : 'artificial',
443
- ...(human ? { interfaces: { telegram: { user_id: 1 } } } : {}),
444
- ...(ephemeral ? { wake_policy: 'ephemeral' } : {}),
445
- }),
446
- )
447
- return cwd
448
- }
449
-
450
- describe('resolveWakeMode (TARGET: death-cause + peer-type/topic)', () => {
451
- let stateDir: string
452
- let cwds: string[]
453
- beforeEach(() => {
454
- stateDir = mkdtempSync(join(tmpdir(), 'iapeer-wakemode-'))
455
- cwds = []
456
- })
457
- afterEach(() => {
458
- rmSync(stateDir, { recursive: true, force: true })
459
- for (const c of cwds) rmSync(c, { recursive: true, force: true })
460
- })
461
- const cfg = () => ({ stateDir } as LifecycleConfig)
462
- const cwd = (human = false, ephemeral = false) => {
463
- const c = profileCwd(human, ephemeral)
464
- cwds.push(c)
465
- return c
466
- }
467
- const hasTranscript = () => ({ ok: true, ref: 'uuid-1' })
468
- const noTranscript = () => ({ ok: false, reason: 'no transcript to resume' })
469
-
470
- // ── branch 1/2: explicit fresh / explicit resume (unchanged) ────────────────
471
- test('argsResume=false (folder-launch) → FRESH', () => {
472
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(), false, hasTranscript)).toEqual({ resume: false, cause: 'folder-launch' })
473
- })
474
- test('argsResume=true (attach) + transcript → RESUME', () => {
475
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
476
- })
477
- test('argsResume=true + nothing to resume → FAIL-LOUD (failReason, no silent fresh)', () => {
478
- const m = resolveWakeMode(cfg(), 'claude-p', cwd(), true, noTranscript)
479
- expect(m.resume).toBe(false)
480
- expect(m.failReason).toMatch(/nothing to resume|no transcript/)
481
- })
482
-
483
- // ── branch 3a: default + NOT idle-reaped → it died on its own → FRESH ────────
484
- test('DEFAULT + NOT idle-reaped (crash/self-close) → FRESH even when a transcript exists', () => {
485
- // INVERSION of the old polarity: absence of the daemon's idle-reaped marker = died
486
- // on its own = clean fresh, NOT a resume of a possibly-broken context.
487
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, hasTranscript)).toEqual({ resume: false, cause: 'crash-or-self-close' })
488
- })
489
-
490
- test('DEFAULT + NOT idle-reaped + NO transcript at all → FRESH with cause=first-wake (not a crash)', () => {
491
- // boris finding (10.06): the first-ever wake of a freshly created peer used to
492
- // read crash-or-self-close — a mis-classification. No transcript = never ran.
493
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(), undefined, noTranscript)).toEqual({ resume: false, cause: 'first-wake' })
494
- })
495
-
496
- // ── stop→start is a CLEAN PARK (boris 10.06): the park marker → RESUME ────────
497
- test('stop→start path: clean-park marker set by stop → the next default wake RESUMES', () => {
498
- const c = cfg()
499
- setIdleReaped(c, 'claude-p') // what the stop verb writes before killing
500
- expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
501
- })
502
-
503
- // ── branch 3b: default + idle-reaped → resume-eligible, CONSUME the marker ───
504
- test('DEFAULT + idle-reaped + human-conversational (interfaces.telegram) → RESUME, marker consumed', () => {
505
- const c = cfg()
506
- setIdleReaped(c, 'claude-p')
507
- const human = cwd(true)
508
- expect(resolveWakeMode(c, 'claude-p', human, undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-human' })
509
- expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed
510
- })
511
- test('DEFAULT + idle-reaped + executor + NO incoming topic → RESUME (continue the work)', () => {
512
- const c = cfg()
513
- setIdleReaped(c, 'claude-p')
514
- expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
515
- })
516
- test('DEFAULT + idle-reaped + executor + SAME topic → RESUME', () => {
517
- const c = cfg()
518
- setIdleReaped(c, 'claude-p')
519
- writeTopic(c, 'claude-p', 'deploy')
520
- expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'deploy')).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'idle-reaped-resume' })
521
- })
522
- test('DEFAULT + idle-reaped + executor + DIFFERENT topic → FRESH (new work), marker consumed', () => {
523
- const c = cfg()
524
- setIdleReaped(c, 'claude-p')
525
- writeTopic(c, 'claude-p', 'deploy')
526
- expect(resolveWakeMode(c, 'claude-p', cwd(false), undefined, hasTranscript, 'unrelated-bug')).toEqual({ resume: false, cause: 'idle-reaped-new-topic' })
527
- expect(hasIdleReaped(c, 'claude-p')).toBe(false) // consumed even on the fresh executor branch
528
- })
529
-
530
- // ── M1: wake_policy "ephemeral" → ALWAYS fresh on delivery, overrides resume ──
531
- test('DEFAULT + ephemeral → FRESH (ephemeral-policy), even with a resumable transcript', () => {
532
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
533
- })
534
- test('DEFAULT + ephemeral + idle-reaped → FRESH (overrides idle-reaped-resume), marker consumed', () => {
535
- const c = cfg()
536
- setIdleReaped(c, 'claude-p')
537
- expect(resolveWakeMode(c, 'claude-p', cwd(false, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
538
- expect(hasIdleReaped(c, 'claude-p')).toBe(false) // stray marker consumed
539
- })
540
- test('DEFAULT + ephemeral + telegram (human) → FRESH (ephemeral WINS over human type)', () => {
541
- const c = cfg()
542
- setIdleReaped(c, 'claude-p')
543
- expect(resolveWakeMode(c, 'claude-p', cwd(true, true), undefined, hasTranscript)).toEqual({ resume: false, cause: 'ephemeral-policy' })
544
- })
545
- test('ephemeral does NOT hijack explicit attach (argsResume=true still resumes)', () => {
546
- // attach is an operator action; ephemeral only governs the delivery path.
547
- expect(resolveWakeMode(cfg(), 'claude-p', cwd(false, true), true, hasTranscript)).toEqual({ resume: true, resumeRef: 'uuid-1', cause: 'attach' })
548
- })
549
- })
550
-
551
- describe('idle-reaped marker round-trip', () => {
552
- test('set/has/clear', () => {
553
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-idlereap-'))
554
- const cfg = { stateDir } as LifecycleConfig
555
- try {
556
- expect(hasIdleReaped(cfg, 'claude-y')).toBe(false)
557
- setIdleReaped(cfg, 'claude-y')
558
- expect(hasIdleReaped(cfg, 'claude-y')).toBe(true)
559
- } finally {
560
- rmSync(stateDir, { recursive: true, force: true })
561
- }
562
- })
563
- })
564
-
565
- describe('new-eager marker round-trip', () => {
566
- test('set/has/clear', () => {
567
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-neweager-'))
568
- const cfg = { stateDir } as LifecycleConfig
569
- try {
570
- expect(hasNewEager(cfg, 'claude-y')).toBe(false)
571
- setNewEager(cfg, 'claude-y')
572
- expect(hasNewEager(cfg, 'claude-y')).toBe(true)
573
- clearNewEager(cfg, 'claude-y')
574
- expect(hasNewEager(cfg, 'claude-y')).toBe(false)
575
- } finally {
576
- rmSync(stateDir, { recursive: true, force: true })
577
- }
578
- })
579
- })
580
-
581
- // ─────────────────────────────────────────────────────────────────────────────
582
- // superviseTick — death-cause accounting (TARGET redesign)
583
- // • idle-reap is the ONLY place .idle-reaped is written
584
- // • a crash/self-close death writes NO marker (lazy fresh on next message)
585
- // • a dead session carrying .new-eager → needs-eager-fresh (mark LEFT for relaunch)
586
- // • every dead session records a death (crash-loop accounting)
587
- // ─────────────────────────────────────────────────────────────────────────────
588
-
589
- describe('superviseTick death-cause accounting (TARGET)', () => {
590
- function deadSessionEnv(personality: string): { env: NodeJS.ProcessEnv; cfg: LifecycleConfig; root: string; laDir: string } {
591
- const root = mkdtempSync(join(tmpdir(), 'iapeer-sup-tgt-root-'))
592
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-sup-tgt-la-')) // empty → not launchd-managed
593
- const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks') }
594
- const cfg = loadLifecycleConfig(env)
595
- mkdirSync(cfg.stateDir, { recursive: true })
596
- writeFileSync(
597
- join(cfg.stateDir, `claude-${personality}.session`),
598
- JSON.stringify({ identity: `claude-${personality}`, runtime: 'claude', personality, cwd: `/tmp/${personality}`, wokeAt: Date.now() }),
599
- )
600
- return { env, cfg, root, laDir }
601
- }
602
-
603
- test('a DEAD session carrying .new-eager → needs-eager-fresh (mark LEFT for relaunch), death recorded', () => {
604
- const { env, cfg, root, laDir } = deadSessionEnv('z')
605
- try {
606
- setNewEager(cfg, 'claude-z')
607
- const out = superviseTick(cfg, { env })
608
- const o = out.find(x => x.identity === 'claude-z')
609
- expect(o?.action).toBe('needs-eager-fresh')
610
- expect(o?.personality).toBe('z')
611
- expect(o?.runtime).toBe('claude')
612
- // the eager mark is LEFT for processEagerRelaunches to consume
613
- expect(hasNewEager(cfg, 'claude-z')).toBe(true)
614
- // every dead session records a death for the crash-loop ring
615
- expect(readDeaths(cfg, 'claude-z').length).toBe(1)
616
- } finally {
617
- rmSync(root, { recursive: true, force: true })
618
- rmSync(laDir, { recursive: true, force: true })
619
- }
620
- })
621
-
622
- test('eager relaunch does NOT pin the dead session runtime — re-resolves from the registry (H5 fleet-switch)', async () => {
623
- // Live incident 12.06: `default-runtime codex` + `self-fresh` resurrected the peer
624
- // on the OLD runtime — the relaunch passed the dead session's runtime EXPLICITLY,
625
- // so resolveWakeRuntime never consulted the just-flipped registry default.
626
- const { env, cfg, root, laDir } = deadSessionEnv('rt')
627
- try {
628
- setNewEager(cfg, 'claude-rt')
629
- const outcomes = superviseTick(cfg, { env })
630
- const o = outcomes.find(x => x.identity === 'claude-rt')
631
- expect(o?.action).toBe('needs-eager-fresh')
632
- const seen: Array<{ personality: string; runtime?: string; resume?: boolean; task: string }> = []
633
- const { processEagerRelaunches } = await import('./index.ts')
634
- await processEagerRelaunches(cfg, outcomes, {
635
- env,
636
- wakeFn: async args => {
637
- seen.push({ personality: args.personality, runtime: args.runtime, resume: args.resume, task: args.task })
638
- return { status: 'READY', woke: true }
639
- },
640
- })
641
- expect(seen.length).toBe(1)
642
- expect(seen[0].personality).toBe('rt')
643
- expect(seen[0].runtime).toBeUndefined() // ← the fix: wakeOrSpawn re-resolves from the registry
644
- expect(seen[0].resume).toBe(false)
645
- expect(seen[0].task).toBe('')
646
- // the mark was consumed by the relaunch
647
- expect(hasNewEager(cfg, 'claude-rt')).toBe(false)
648
- } finally {
649
- rmSync(root, { recursive: true, force: true })
650
- rmSync(laDir, { recursive: true, force: true })
651
- }
652
- })
653
-
654
- test('a DEAD session with NO .new-eager → reaped-gone, NO .idle-reaped written (crash leaves no marker)', () => {
655
- const { env, cfg, root, laDir } = deadSessionEnv('w')
656
- try {
657
- const out = superviseTick(cfg, { env })
658
- expect(out.find(x => x.identity === 'claude-w')?.action).toBe('reaped-gone')
659
- // a crash/self-close is NOT daemon-initiated → no idle-reaped marker → next wake FRESH
660
- expect(hasIdleReaped(cfg, 'claude-w')).toBe(false)
661
- expect(readDeaths(cfg, 'claude-w').length).toBe(1)
662
- } finally {
663
- rmSync(root, { recursive: true, force: true })
664
- rmSync(laDir, { recursive: true, force: true })
665
- }
666
- })
667
-
668
- test('a DEAD session clears a stale .ephemeral-armed (the mark dies with its session)', () => {
669
- // The mark armed on the dead session's outbound; were it to survive, the NEXT
670
- // session would be quiet-reap eligible BEFORE answering its own task.
671
- const { env, cfg, root, laDir } = deadSessionEnv('q')
672
- try {
673
- setEphemeralArmed(cfg, 'claude-q')
674
- const out = superviseTick(cfg, { env })
675
- expect(out.find(x => x.identity === 'claude-q')?.action).toBe('reaped-gone')
676
- expect(hasEphemeralArmed(cfg, 'claude-q')).toBe(false)
677
- } finally {
678
- rmSync(root, { recursive: true, force: true })
679
- rmSync(laDir, { recursive: true, force: true })
680
- }
681
- })
682
- })
683
-
684
- // ─────────────────────────────────────────────────────────────────────────────
685
- // wake_policy:ephemeral M2 — armed marker + quiet-reap (die-after-reply)
686
- // ─────────────────────────────────────────────────────────────────────────────
687
-
688
- describe('ephemeral-armed marker + config', () => {
689
- test('set/has/clear round-trip; clear is idempotent', () => {
690
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-eph-mark-'))
691
- const cfg = { stateDir } as LifecycleConfig
692
- try {
693
- expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(false)
694
- setEphemeralArmed(cfg, 'claude-e')
695
- expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(true)
696
- clearEphemeralArmed(cfg, 'claude-e')
697
- clearEphemeralArmed(cfg, 'claude-e') // idempotent
698
- expect(hasEphemeralArmed(cfg, 'claude-e')).toBe(false)
699
- } finally {
700
- rmSync(stateDir, { recursive: true, force: true })
701
- }
702
- })
703
-
704
- test('ephemeralQuietSecs: default 20, env-tunable', () => {
705
- expect(loadLifecycleConfig({ HOME: '/tmp' } as NodeJS.ProcessEnv).ephemeralQuietSecs).toBe(20)
706
- expect(
707
- loadLifecycleConfig({ HOME: '/tmp', IAPEER_EPHEMERAL_QUIET_SECS: '45' } as NodeJS.ProcessEnv)
708
- .ephemeralQuietSecs,
709
- ).toBe(45)
710
- })
711
-
712
- test('isEphemeralPeer keys on the cwd profile; read hiccup → false (safe default)', () => {
713
- const eph = profileCwd(false, true)
714
- const plain = profileCwd(false, false)
715
- try {
716
- expect(isEphemeralPeer(eph)).toBe(true)
717
- expect(isEphemeralPeer(plain)).toBe(false)
718
- expect(isEphemeralPeer('/tmp/definitely-no-such-peer-cwd')).toBe(false)
719
- } finally {
720
- rmSync(eph, { recursive: true, force: true })
721
- rmSync(plain, { recursive: true, force: true })
722
- }
723
- })
724
- })
725
-
726
- // ─────────────────────────────────────────────────────────────────────────────
727
- // classifyGoneSession — the death-class tag for reaped-gone (server-dead vs
728
- // session-gone). Live case: iapeer-memory 10.06 — the whole tmux server died
729
- // (SIGKILL class), exits.log stayed empty; lifecycle.log must carry the class.
730
- // ─────────────────────────────────────────────────────────────────────────────
731
-
732
- describe('classifyGoneSession (death-class tag)', () => {
733
- const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
734
-
735
- test('missing socket file → server-dead', () => {
736
- const r = classifyGoneSession(join(tmpdir(), 'iapeer-no-such-sock-ever.sock'))
737
- expect(r.death).toBe('server-dead')
738
- expect(r.reason).toContain('socket file missing')
739
- })
740
-
741
- test('hosted peer → host-framed reason, not tmux', () => {
742
- // A pty-supervisor-hosted peer never had a tmux socket; the tmux-framed "socket file
743
- // missing" would misattribute its death. hosted=true short-circuits to the supervisor framing.
744
- const r = classifyGoneSession(join(tmpdir(), 'iapeer-no-such-sock-ever.sock'), true)
745
- expect(r.death).toBe('server-dead')
746
- expect(r.reason).toContain('pty host')
747
- expect(r.reason).not.toContain('tmux server')
748
- })
749
-
750
- test('stale socket (file exists, nothing serves it) → server-dead', () => {
751
- // The SIGKILL/OOM class: the killed server never unlinks its socket file.
752
- const dir = mkdtempSync(join(tmpdir(), 'iapeer-stale-sock-'))
753
- const sock = join(dir, 'tmux-iap-claude-stale.sock')
754
- try {
755
- writeFileSync(sock, '') // a plain file — tmux cannot connect to it
756
- const r = classifyGoneSession(sock)
757
- expect(r.death).toBe('server-dead')
758
- expect(r.reason).toContain('stale socket')
759
- } finally {
760
- rmSync(dir, { recursive: true, force: true })
761
- }
762
- })
763
-
764
- test.if(tmuxAvailable)('server alive but the session is not there → session-gone', () => {
765
- const dir = mkdtempSync(join(tmpdir(), 'iapeer-sg-sock-'))
766
- const sock = join(dir, 'tmux-iap-claude-sg.sock')
767
- try {
768
- // a LIVING server on the socket holding some OTHER session
769
- spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'other-session', 'sleep', '60'])
770
- const r = classifyGoneSession(sock)
771
- expect(r.death).toBe('session-gone')
772
- expect(r.reason).toContain('server alive')
773
- } finally {
774
- spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
775
- rmSync(dir, { recursive: true, force: true })
776
- }
777
- })
778
-
779
- test.if(tmuxAvailable)('superviseTick logs death=session-gone when the pane died inside a living server', () => {
780
- const root = mkdtempSync(join(tmpdir(), 'iapeer-sg-root-'))
781
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-sg-la-')) // empty → not launchd-managed
782
- const env = {
783
- ...process.env,
784
- IAPEER_ROOT: root,
785
- IAPEER_LAUNCHAGENTS_DIR: laDir,
786
- IAPEER_SOCK_DIR: join(root, 'socks'),
787
- }
788
- const cfg = loadLifecycleConfig(env)
789
- const identity = 'claude-sg'
790
- const sock = join(root, 'socks', 'tmux-iap-claude-sg.sock')
791
- try {
792
- mkdirSync(join(root, 'socks'), { recursive: true })
793
- mkdirSync(cfg.stateDir, { recursive: true })
794
- // the server LIVES (another session holds it) but claude-sg's session is gone
795
- spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', 'placeholder', 'sleep', '60'])
796
- writeFileSync(
797
- join(cfg.stateDir, `${identity}.session`),
798
- JSON.stringify({ identity, runtime: 'claude', personality: 'sg', cwd: '/tmp/none', wokeAt: 0 }),
799
- )
800
- const out = superviseTick(cfg, { env })
801
- expect(out.find(x => x.identity === identity)?.action).toBe('reaped-gone')
802
- const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
803
- expect(logged).toContain(`identity=${identity} action=reaped-gone death=session-gone`)
804
- } finally {
805
- spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
806
- rmSync(root, { recursive: true, force: true })
807
- rmSync(laDir, { recursive: true, force: true })
808
- }
809
- })
810
- })
811
-
812
- describe('superviseTick quiet-reap (M2 die-after-reply, real tmux)', () => {
813
- const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
814
-
815
- test.if(tmuxAvailable)(
816
- 'ARMED + quiet → reaped-ephemeral (killed, marks cleared, NO death/idle-reaped); unarmed/not-quiet → alive',
817
- () => {
818
- const root = mkdtempSync(join(tmpdir(), 'iapeer-eq-root-'))
819
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eq-la-')) // empty → not launchd-managed
820
- const cwd = profileCwd(false, true) // ephemeral worker profile
821
- const env = {
822
- ...process.env,
823
- IAPEER_ROOT: root,
824
- IAPEER_LAUNCHAGENTS_DIR: laDir,
825
- IAPEER_SOCK_DIR: join(root, 'socks'),
826
- }
827
- const cfg = loadLifecycleConfig(env) // ephemeralQuietSecs 20 ≪ idleSecs 3600
828
- const identity = 'claude-eq'
829
- const sock = join(root, 'socks', 'tmux-iap-claude-eq.sock')
830
- const writeState = (wokeAt: number) => {
831
- mkdirSync(cfg.stateDir, { recursive: true })
832
- writeFileSync(
833
- join(cfg.stateDir, `${identity}.session`),
834
- JSON.stringify({ identity, runtime: 'claude', personality: 'eq', cwd, wokeAt }),
835
- )
836
- }
837
- const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
838
- try {
839
- mkdirSync(join(root, 'socks'), { recursive: true })
840
- spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
841
- expect(alive()).toBe(true)
842
- // no transcript in the temp cwd → activity proxy = wokeAt fallback:
843
- // quiet age is fully controlled by the .session wokeAt below.
844
-
845
- // 1) NOT armed + quiet-aged → alive (a silent long tool-run is NOT reaped:
846
- // sleep-180 protection; only the ordinary idle bound applies).
847
- writeState(Date.now() - 60_000) // age ~60s > quiet 20s, ≪ idle 3600s
848
- expect(superviseTick(cfg, { env }).find(x => x.identity === identity)?.action).toBe('alive')
849
- expect(alive()).toBe(true)
850
-
851
- // 2) ARMED but NOT quiet → alive (post-reply housekeeping keeps it alive).
852
- setEphemeralArmed(cfg, identity)
853
- writeState(Date.now()) // age ~0 < quiet
854
- expect(superviseTick(cfg, { env }).find(x => x.identity === identity)?.action).toBe('alive')
855
- expect(alive()).toBe(true)
856
-
857
- // 3) ARMED + quiet → reaped-ephemeral, with the M3 drain fields.
858
- writeState(Date.now() - 60_000)
859
- const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
860
- expect(o?.action).toBe('reaped-ephemeral')
861
- expect(o?.personality).toBe('eq')
862
- expect(o?.runtime).toBe('claude')
863
- expect(alive()).toBe(false) // session killed
864
- expect(hasEphemeralArmed(cfg, identity)).toBe(false) // mark consumed
865
- expect(existsSync(join(cfg.stateDir, `${identity}.session`))).toBe(false)
866
- // deliberate policy death: never resume-eligible, never a crash-loop count
867
- expect(hasIdleReaped(cfg, identity)).toBe(false)
868
- expect(readDeaths(cfg, identity).length).toBe(0)
869
- // durable decision trace
870
- const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
871
- expect(logged).toContain(`action=reaped-ephemeral`)
872
- expect(logged).toContain('outcome=ephemeral-done')
873
- } finally {
874
- spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
875
- rmSync(root, { recursive: true, force: true })
876
- rmSync(laDir, { recursive: true, force: true })
877
- rmSync(cwd, { recursive: true, force: true })
878
- }
879
- },
880
- 30000,
881
- )
882
-
883
- test.if(tmuxAvailable)(
884
- 'UNARMED ephemeral past the unarmed idle bound → reaped-ephemeral (silent-finish backstop; live case scriber 10.06)',
885
- () => {
886
- const root = mkdtempSync(join(tmpdir(), 'iapeer-eu-root-'))
887
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-eu-la-'))
888
- const cwd = profileCwd(false, true) // ephemeral worker profile
889
- const env = {
890
- ...process.env,
891
- IAPEER_ROOT: root,
892
- IAPEER_LAUNCHAGENTS_DIR: laDir,
893
- IAPEER_SOCK_DIR: join(root, 'socks'),
894
- IAPEER_EPHEMERAL_UNARMED_IDLE_SECS: '30', // ≪ the 60s age below, ≫ quiet 20s
895
- }
896
- const cfg = loadLifecycleConfig(env)
897
- const identity = 'claude-eu'
898
- const sock = join(root, 'socks', 'tmux-iap-claude-eu.sock')
899
- const alive = () => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity]).status === 0
900
- try {
901
- mkdirSync(join(root, 'socks'), { recursive: true })
902
- spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '300'])
903
- expect(alive()).toBe(true)
904
- mkdirSync(cfg.stateDir, { recursive: true })
905
- writeFileSync(
906
- join(cfg.stateDir, `${identity}.session`),
907
- JSON.stringify({ identity, runtime: 'claude', personality: 'eu', cwd, wokeAt: Date.now() - 60_000 }),
908
- )
909
- // NOT armed (the worker ended silently) — past the unarmed bound → policy reap
910
- const o = superviseTick(cfg, { env }).find(x => x.identity === identity)
911
- expect(o?.action).toBe('reaped-ephemeral')
912
- expect(o?.reason).toContain('unarmed idle')
913
- expect(o?.personality).toBe('eu') // M3 drain fields present → queue feeds next
914
- expect(alive()).toBe(false)
915
- // policy death: no resume-eligibility, no crash-loop count
916
- expect(hasIdleReaped(cfg, identity)).toBe(false)
917
- expect(readDeaths(cfg, identity).length).toBe(0)
918
- const logged = readFileSync(join(cfg.eventLogDir, 'lifecycle.log'), 'utf8')
919
- expect(logged).toContain('outcome=ephemeral-unarmed-bound')
920
- // a NON-ephemeral peer with the same age is untouched by this bound
921
- // (its session lives on ITS OWN identity-derived socket — the tick keys
922
- // sockets on runtime-personality, not on the test's prior sock)
923
- const plainCwd = profileCwd(false, false)
924
- const eupSock = join(root, 'socks', 'tmux-iap-claude-eup.sock')
925
- try {
926
- writeFileSync(
927
- join(cfg.stateDir, `claude-eup.session`),
928
- JSON.stringify({ identity: 'claude-eup', runtime: 'claude', personality: 'eup', cwd: plainCwd, wokeAt: Date.now() - 60_000 }),
929
- )
930
- spawnSync('tmux', ['-S', eupSock, 'new-session', '-d', '-s', 'claude-eup', 'sleep', '300'])
931
- expect(superviseTick(cfg, { env }).find(x => x.identity === 'claude-eup')?.action).toBe('alive')
932
- } finally {
933
- spawnSync('tmux', ['-S', eupSock, 'kill-server'], { stdio: 'ignore' })
934
- rmSync(plainCwd, { recursive: true, force: true })
935
- }
936
- } finally {
937
- spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
938
- rmSync(root, { recursive: true, force: true })
939
- rmSync(laDir, { recursive: true, force: true })
940
- rmSync(cwd, { recursive: true, force: true })
941
- }
942
- },
943
- 30000,
944
- )
945
- })
946
-
947
- // ─────────────────────────────────────────────────────────────────────────────
948
- // Crash-loop guard — wakeOrSpawn refuses to (re)launch after N deaths in the window
949
- // ─────────────────────────────────────────────────────────────────────────────
950
-
951
- describe('crash-loop guard', () => {
952
- test('countRecentDeaths windows correctly; recordDeath rings', () => {
953
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-deaths-'))
954
- const cfg = { stateDir } as LifecycleConfig
955
- try {
956
- const now = 1_000_000
957
- recordDeath(cfg, 'claude-d', now - 400_000) // outside a 300s window
958
- recordDeath(cfg, 'claude-d', now - 10_000)
959
- recordDeath(cfg, 'claude-d', now - 5_000)
960
- expect(countRecentDeaths(cfg, 'claude-d', 300, now)).toBe(2)
961
- expect(countRecentDeaths(cfg, 'claude-d', 600, now)).toBe(3)
962
- } finally {
963
- rmSync(stateDir, { recursive: true, force: true })
964
- }
965
- })
966
-
967
- test('wakeOrSpawn REFUSES after crashLoopMax deaths within the window (FAILED, no launch)', async () => {
968
- const root = mkdtempSync(join(tmpdir(), 'iapeer-clg-root-'))
969
- const laDir = mkdtempSync(join(tmpdir(), 'iapeer-clg-la-')) // empty → not launchd-managed
970
- const peerCwd = mkdtempSync(join(tmpdir(), 'iapeer-clg-cwd-')) // REAL cwd so the cwd-existence check passes and the guard is what fires
971
- try {
972
- await upsertPeer(
973
- { personality: 'clg', runtime: 'claude', cwd: peerCwd, intelligence: 'artificial' },
974
- { rootDir: root },
975
- )
976
- const env = { ...process.env, IAPEER_ROOT: root, IAPEER_LAUNCHAGENTS_DIR: laDir, IAPEER_SOCK_DIR: join(root, 'socks'), IAPEER_CRASHLOOP_MAX: '3', IAPEER_CRASHLOOP_WINDOW_SECS: '300' }
977
- const cfg = loadLifecycleConfig(env)
978
- const now = Date.now()
979
- recordDeath(cfg, 'claude-clg', now)
980
- recordDeath(cfg, 'claude-clg', now)
981
- recordDeath(cfg, 'claude-clg', now)
982
- const r = await wakeOrSpawn({ personality: 'clg', runtime: 'claude', task: 'must not launch' }, { env })
983
- expect(r.status).toBe('FAILED')
984
- expect(r.woke).toBe(false)
985
- expect(r.reason).toMatch(/crash-loop guard/)
986
- } finally {
987
- rmSync(root, { recursive: true, force: true })
988
- rmSync(laDir, { recursive: true, force: true })
989
- rmSync(peerCwd, { recursive: true, force: true })
990
- }
991
- })
992
- })
993
-
994
- // ─────────────────────────────────────────────────────────────────────────────
995
- // .topic — executor discriminator round-trip
996
- // ─────────────────────────────────────────────────────────────────────────────
997
-
998
- describe('topic marker round-trip', () => {
999
- test('writeTopic/readTopic', () => {
1000
- const stateDir = mkdtempSync(join(tmpdir(), 'iapeer-topic-'))
1001
- const cfg = { stateDir } as LifecycleConfig
1002
- try {
1003
- expect(readTopic(cfg, 'claude-t')).toBe('')
1004
- writeTopic(cfg, 'claude-t', 'deploy-pipeline')
1005
- expect(readTopic(cfg, 'claude-t')).toBe('deploy-pipeline')
1006
- } finally {
1007
- rmSync(stateDir, { recursive: true, force: true })
1008
- }
1009
- })
1010
- })
1011
-
1012
- // ─────────────────────────────────────────────────────────────────────────────
1013
- // Ф-D launch / attach — operator verbs (error paths; success paths are live-verified)
1014
- // ─────────────────────────────────────────────────────────────────────────────
1015
-
1016
- describe('lastActiveRuntime', () => {
1017
- test('a peer with no transcript anywhere → undefined (never run)', () => {
1018
- const cfg = { sockDir: '/tmp' } as LifecycleConfig
1019
- const rt = lastActiveRuntime(peer({ personality: 'np', runtimes: ['claude', 'codex'], cwd: '/tmp/does-not-exist-xyz' }), cfg)
1020
- expect(rt).toBeUndefined()
1021
- })
1022
- })
1023
-
1024
- describe('attachPeer (error paths)', () => {
1025
- test('an unregistered peer → ok:false', async () => {
1026
- const root = mkdtempSync(join(tmpdir(), 'iapeer-att-'))
1027
- try {
1028
- const r = await attachPeer({ personality: 'ghost', env: { ...process.env, IAPEER_ROOT: root } })
1029
- expect(r.ok).toBe(false)
1030
- if (!r.ok) expect(r.reason).toMatch(/not registered/)
1031
- } finally {
1032
- rmSync(root, { recursive: true, force: true })
1033
- }
1034
- })
1035
- test('an explicit UNDECLARED runtime → ok:false (fail-loud)', async () => {
1036
- const root = mkdtempSync(join(tmpdir(), 'iapeer-att2-'))
1037
- try {
1038
- await upsertPeer({ personality: 'solo', runtime: 'claude', cwd: '/tmp/solo', intelligence: 'artificial' }, { rootDir: root })
1039
- const r = await attachPeer({ personality: 'solo', runtime: 'codex', env: { ...process.env, IAPEER_ROOT: root } })
1040
- expect(r.ok).toBe(false)
1041
- } finally {
1042
- rmSync(root, { recursive: true, force: true })
1043
- }
1044
- })
1045
- })
1046
-
1047
- describe('folderLaunch (error path)', () => {
1048
- test('a cwd without a peer-profile → throws (resolveIdentity fail-loud)', async () => {
1049
- const cwd = mkdtempSync(join(tmpdir(), 'iapeer-fl-'))
1050
- try {
1051
- await expect(folderLaunch({ cwd, env: { ...process.env } })).rejects.toThrow()
1052
- } finally {
1053
- rmSync(cwd, { recursive: true, force: true })
1054
- }
1055
- })
1056
- })