@agfpd/iapeer 0.2.76 → 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.
- package/LICENSE +202 -0
- package/README.md +2 -2
- package/package.json +21 -2
- package/src/cli/index.ts +47 -48
- package/src/connect/index.ts +4 -4
- package/src/core/constants.ts +3 -3
- package/src/core/normalize.ts +2 -2
- package/src/create/index.ts +1 -1
- package/src/daemon/index.ts +5 -5
- package/src/daemon/main.ts +9 -9
- package/src/enable/index.ts +19 -18
- package/src/enable/provisionCommand.ts +10 -10
- package/src/identity/index.ts +2 -2
- package/src/identity/profileStandard.ts +8 -8
- package/src/init/index.ts +20 -13
- package/src/install/index.ts +2 -2
- package/src/install/signing.ts +3 -4
- package/src/launch/adapters/claude.ts +7 -7
- package/src/launch/adapters/codex.ts +5 -6
- package/src/launch/canary.ts +8 -8
- package/src/launch/cmdlog.ts +3 -3
- package/src/launch/composeSystemPrompt.ts +2 -1
- package/src/launch/index.ts +5 -6
- package/src/launch/invocation.ts +1 -1
- package/src/launch/launchd.ts +14 -15
- package/src/launch/launchdRun.ts +6 -6
- package/src/launch/nativeMemory.ts +9 -9
- package/src/launch/ptyHost.ts +1 -1
- package/src/launch/readyGateModel.ts +3 -3
- package/src/launch/types.ts +7 -7
- package/src/lifecycle/eventlog.ts +2 -2
- package/src/lifecycle/index.ts +21 -21
- package/src/lifecycle/queue.ts +1 -1
- package/src/onboard/index.ts +19 -13
- package/src/onboard/memory.ts +5 -5
- package/src/onboard/steps.ts +8 -9
- package/src/provision/index.ts +2 -3
- package/src/runtime/deploy.ts +2 -2
- package/src/runtime/index.ts +4 -4
- package/src/shadow/gate.ts +6 -6
- package/src/shadow/index.ts +2 -2
- package/src/status/index.ts +11 -11
- package/src/storage/index.ts +1 -1
- package/src/supervisor/daemon.ts +14 -8
- package/src/transport/index.ts +11 -11
- package/src/update/index.ts +4 -4
- package/src/cli/cli.test.ts +0 -603
- package/src/cli/listTui.test.ts +0 -71
- package/src/codec/codec.test.ts +0 -271
- package/src/connect/connect.test.ts +0 -163
- package/src/core/constants.test.ts +0 -32
- package/src/core/normalize.test.ts +0 -98
- package/src/create/create.test.ts +0 -143
- package/src/daemon/daemon-http.test.ts +0 -114
- package/src/daemon/daemon.test.ts +0 -267
- package/src/daemon/main.test.ts +0 -413
- package/src/enable/enable.test.ts +0 -95
- package/src/enable/provisionCommand.test.ts +0 -107
- package/src/identity/identity.test.ts +0 -279
- package/src/identity/profileStandard.test.ts +0 -204
- package/src/init/init.test.ts +0 -213
- package/src/init/runtime-resolve.test.ts +0 -49
- package/src/install/install.test.ts +0 -66
- package/src/install/signing.test.ts +0 -84
- package/src/launch/bootdeliver.test.ts +0 -108
- package/src/launch/bootstrap.test.ts +0 -56
- package/src/launch/canary.test.ts +0 -346
- package/src/launch/cmdlog.test.ts +0 -94
- package/src/launch/codexHooksTrust.test.ts +0 -248
- package/src/launch/composeSystemPrompt.layers.test.ts +0 -445
- package/src/launch/composeSystemPrompt.test.ts +0 -145
- package/src/launch/hostComposerOccupied.test.ts +0 -63
- package/src/launch/invocation.test.ts +0 -174
- package/src/launch/launch.test.ts +0 -359
- package/src/launch/launchd.test.ts +0 -499
- package/src/launch/nativeMemory.test.ts +0 -193
- package/src/launch/ptyHost.test.ts +0 -38
- package/src/launch/readyGateModel.test.ts +0 -72
- package/src/launch/sockdir.test.ts +0 -74
- package/src/lifecycle/eventlog.test.ts +0 -114
- package/src/lifecycle/lifecycle.test.ts +0 -1056
- package/src/lifecycle/queue.test.ts +0 -185
- package/src/onboard/memory.test.ts +0 -157
- package/src/onboard/onboard.test.ts +0 -39
- package/src/onboard/steps.test.ts +0 -201
- package/src/provision/provision.test.ts +0 -288
- package/src/registry/registry.test.ts +0 -432
- package/src/runtime/runtime.test.ts +0 -226
- package/src/runtime/update.test.ts +0 -192
- package/src/shadow/enumerate-skip.test.ts +0 -67
- package/src/shadow/gate.test.ts +0 -286
- package/src/shadow/install.test.ts +0 -153
- package/src/shadow/shadow.test.ts +0 -42
- package/src/status/status.test.ts +0 -208
- package/src/storage/peers-home.test.ts +0 -34
- package/src/storage/rotatelog.test.ts +0 -44
- package/src/storage/storage.test.ts +0 -65
- package/src/supervisor/attached-marker.test.ts +0 -84
- package/src/supervisor/boot.test.ts +0 -66
- package/src/supervisor/client.attach.test.ts +0 -107
- package/src/supervisor/daemon.test.ts +0 -213
- package/src/supervisor/deliver.test.ts +0 -58
- package/src/supervisor/panelog.test.ts +0 -95
- package/src/supervisor/protocol.test.ts +0 -222
- package/src/supervisor/selfargv.test.ts +0 -22
- package/src/supervisor/spinner-strip.test.ts +0 -52
- package/src/transport/compactDone.test.ts +0 -144
- package/src/transport/composerQueue.test.ts +0 -204
- package/src/transport/hostControl.test.ts +0 -87
- package/src/transport/hostDeliver.test.ts +0 -137
- package/src/transport/hostLive.test.ts +0 -50
- package/src/transport/routesend-wake.test.ts +0 -357
- 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
|
-
})
|