@agfpd/iapeer 0.2.22 → 0.2.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/cli.test.ts +26 -1
- package/src/cli/index.ts +18 -1
- package/src/launch/canary.test.ts +28 -1
- package/src/launch/launchdRun.ts +30 -0
- package/src/lifecycle/index.ts +38 -0
package/package.json
CHANGED
package/src/cli/cli.test.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { tmpdir } from 'os'
|
|
|
9
9
|
import { join } from 'path'
|
|
10
10
|
import { formatListTable, listPeers, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
|
|
11
11
|
import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
|
|
12
|
-
import { hasIdleReaped, isStopped, loadLifecycleConfig, setStopped } from '../lifecycle/index.ts'
|
|
12
|
+
import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
|
|
13
13
|
import { launchdPlistPath } from '../launch/launchd.ts'
|
|
14
14
|
|
|
15
15
|
let root: string
|
|
@@ -130,6 +130,31 @@ describe('remove (registry record via the locked writer)', () => {
|
|
|
130
130
|
expect((await removePeerCli('twice', { env: e })).action).toBe('removed')
|
|
131
131
|
expect((await removePeerCli('twice', { env: e })).action).toBe('absent')
|
|
132
132
|
})
|
|
133
|
+
test('purges identity-keyed lifecycle state with the record — a namesake newborn must not inherit a dead peer\'s parking (boris 10.06)', async () => {
|
|
134
|
+
await register('reborn')
|
|
135
|
+
const e = env()
|
|
136
|
+
const cfg = loadLifecycleConfig(e)
|
|
137
|
+
// the dead peer left the full marker cemetery behind (the live-defect shape:
|
|
138
|
+
// .stopped + .idle-reaped → a namesake newborn is REFUSED its wake)
|
|
139
|
+
setStopped(cfg, 'claude-reborn')
|
|
140
|
+
setIdleReaped(cfg, 'claude-reborn')
|
|
141
|
+
writeFileSync(join(cfg.stateDir, 'claude-reborn.topic'), 'old-topic')
|
|
142
|
+
mkdirSync(join(cfg.stateDir, 'claude-reborn.queue'), { recursive: true })
|
|
143
|
+
// a NEIGHBOR identity sharing the name as a PREFIX must survive untouched
|
|
144
|
+
setStopped(cfg, 'claude-reborn2')
|
|
145
|
+
|
|
146
|
+
const o = await removePeerCli('reborn', { env: e })
|
|
147
|
+
expect(o.action).toBe('removed')
|
|
148
|
+
expect(o.purgedState?.sort()).toEqual([
|
|
149
|
+
'claude-reborn.idle-reaped',
|
|
150
|
+
'claude-reborn.queue',
|
|
151
|
+
'claude-reborn.stopped',
|
|
152
|
+
'claude-reborn.topic',
|
|
153
|
+
])
|
|
154
|
+
expect(isStopped(cfg, 'claude-reborn')).toBe(false) // the newborn namesake wakes
|
|
155
|
+
expect(existsSync(join(cfg.stateDir, 'claude-reborn.queue'))).toBe(false)
|
|
156
|
+
expect(isStopped(cfg, 'claude-reborn2')).toBe(true) // dot-delimited: no prefix bleed
|
|
157
|
+
})
|
|
133
158
|
})
|
|
134
159
|
|
|
135
160
|
describe('send validation', () => {
|
package/src/cli/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
isStopped,
|
|
34
34
|
killSession,
|
|
35
35
|
loadLifecycleConfig,
|
|
36
|
+
purgeIdentityState,
|
|
36
37
|
removeSessionState,
|
|
37
38
|
setIdleReaped,
|
|
38
39
|
setNewEager,
|
|
@@ -271,6 +272,11 @@ export interface RemoveOutcome {
|
|
|
271
272
|
* deliberately keeps the folder — user data is never deleted by a registry reap
|
|
272
273
|
* (boris's finding 10.06: say so in the output instead of leaving silent orphans). */
|
|
273
274
|
cwd?: string
|
|
275
|
+
/** Identity-keyed lifecycle artifacts purged with the record (state/lifecycle/
|
|
276
|
+
* `<identity>.*` per runtime). Without this purge a NEWBORN peer reusing the
|
|
277
|
+
* personality inherits the dead namesake's parking (live defect, boris 10.06:
|
|
278
|
+
* stale .stopped → `mode=refused cause=stopped` on a freshly-created peer). */
|
|
279
|
+
purgedState?: string[]
|
|
274
280
|
}
|
|
275
281
|
|
|
276
282
|
/**
|
|
@@ -301,7 +307,13 @@ export async function removePeerCli(
|
|
|
301
307
|
}
|
|
302
308
|
}
|
|
303
309
|
await removePeer(personality, { env })
|
|
304
|
-
|
|
310
|
+
// Purge identity-keyed lifecycle state WITH the record (per runtime): stale
|
|
311
|
+
// .stopped/.idle-reaped/... must never outlive the peer and ambush a future
|
|
312
|
+
// namesake (purgeIdentityState doc). After the registry write, so a failed
|
|
313
|
+
// remove never half-purges a still-registered peer.
|
|
314
|
+
const cfg = loadLifecycleConfig(env)
|
|
315
|
+
const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
|
|
316
|
+
return { personality, action: 'removed', cwd: peer.cwd, purgedState }
|
|
305
317
|
}
|
|
306
318
|
|
|
307
319
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -692,6 +704,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
692
704
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
693
705
|
if (o.action === 'removed') {
|
|
694
706
|
out(`removed "${o.personality}" from the registry\n`)
|
|
707
|
+
// Stale identity-keyed markers must die with the record (boris 10.06: a
|
|
708
|
+
// namesake newborn inherited a dead peer's .stopped → refused to wake).
|
|
709
|
+
if (o.purgedState?.length) {
|
|
710
|
+
out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
|
|
711
|
+
}
|
|
695
712
|
// Deliberate: the registry reap never deletes user data — but SAY so, or
|
|
696
713
|
// the default-location peers leave silent orphan folders (boris 10.06).
|
|
697
714
|
if (o.cwd && existsSync(o.cwd)) {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
signalCanaryClean,
|
|
18
18
|
} from './canary.ts'
|
|
19
19
|
import { killSession } from '../lifecycle/index.ts'
|
|
20
|
+
import { teardownAlwaysOnSession } from './launchdRun.ts'
|
|
20
21
|
|
|
21
22
|
const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
22
23
|
|
|
@@ -92,7 +93,7 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
92
93
|
afterAll(() => {
|
|
93
94
|
for (const sock of socks) {
|
|
94
95
|
// teardown is DELIBERATE → signal each canary before killing its server
|
|
95
|
-
for (const id of ['claude-canadirty', 'claude-canaclean', 'claude-canakill']) {
|
|
96
|
+
for (const id of ['claude-canadirty', 'claude-canaclean', 'claude-canakill', 'notifier-canatear']) {
|
|
96
97
|
signalCanaryClean(sock, id)
|
|
97
98
|
}
|
|
98
99
|
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
@@ -152,6 +153,32 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
|
|
|
152
153
|
20000,
|
|
153
154
|
)
|
|
154
155
|
|
|
156
|
+
test(
|
|
157
|
+
'teardownAlwaysOnSession (signal-exit of runAlwaysOn) kills session+server, canary stays silent',
|
|
158
|
+
async () => {
|
|
159
|
+
const identity = 'notifier-canatear'
|
|
160
|
+
const { sock, logDir } = bringUp(identity)
|
|
161
|
+
expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
|
|
162
|
+
expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
|
|
163
|
+
await sleep(500)
|
|
164
|
+
|
|
165
|
+
teardownAlwaysOnSession(sock, identity) // the bootout/shutdown path
|
|
166
|
+
// the whole server must be gone (the poller dies WITH the watcher — грабля closed)
|
|
167
|
+
expect(
|
|
168
|
+
await waitFor(
|
|
169
|
+
() => spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status !== 0,
|
|
170
|
+
3000,
|
|
171
|
+
),
|
|
172
|
+
).toBe(true)
|
|
173
|
+
expect(await waitFor(() => !canaryRunning(identity), 3000)).toBe(true)
|
|
174
|
+
await sleep(300)
|
|
175
|
+
expect(existsSync(exitLogPath(logDir)) && readFileSync(exitLogPath(logDir), 'utf8').includes('ev=server-exit')).toBe(
|
|
176
|
+
false,
|
|
177
|
+
)
|
|
178
|
+
},
|
|
179
|
+
20000,
|
|
180
|
+
)
|
|
181
|
+
|
|
155
182
|
test(
|
|
156
183
|
'killSession (lifecycle clean reap) signals the canary before kill-server → no record',
|
|
157
184
|
async () => {
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { buildProcessAddress, buildSocketPath } from '../core/socket.ts'
|
|
|
23
23
|
import { peerLogsDir, pluginLogsDir } from '../storage/index.ts'
|
|
24
24
|
import { readPeerProfile } from '../identity/index.ts'
|
|
25
25
|
import { getAdapter, launch } from './index.ts'
|
|
26
|
+
import { signalCanaryClean } from './canary.ts'
|
|
26
27
|
import type { LaunchConfig, LaunchSpec } from './types.ts'
|
|
27
28
|
|
|
28
29
|
/** Block-watch poll cadence — seconds, deliberately NOT a tight loop (the session
|
|
@@ -39,6 +40,29 @@ function sessionAlive(sock: string, identity: string): boolean {
|
|
|
39
40
|
return spawnSync('tmux', ['-S', sock, 'has-session', '-t', identity], { stdio: 'ignore' }).status === 0
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Tear down the always-on session WITH its tmux server (when this session was the
|
|
45
|
+
* last one) — the signal-exit counterpart of lifecycle.killSession, local to avoid
|
|
46
|
+
* a launch ⇆ lifecycle import. Canary-signaled first: this is a DELIBERATE stop.
|
|
47
|
+
*
|
|
48
|
+
* Closes the live грабля «bootout не убивает поллера» (boris 10.06, second
|
|
49
|
+
* strike): `launchctl bootout` TERMs THIS watcher process, but the detached tmux
|
|
50
|
+
* server — and the runtime poller inside it — survived holding STALE in-memory
|
|
51
|
+
* state (e.g. a notifier trigger's old target after a same-id replace), so a
|
|
52
|
+
* plain bootout+bootstrap was NOT a real restart. With the teardown, the session
|
|
53
|
+
* dies with its watcher: bootout = full stop, bootstrap = fresh bring-up that
|
|
54
|
+
* re-reads durable state. `iapeer stop` (bootout + killSession) is unchanged —
|
|
55
|
+
* both paths now converge on the same end state.
|
|
56
|
+
*/
|
|
57
|
+
export function teardownAlwaysOnSession(sock: string, identity: string): void {
|
|
58
|
+
signalCanaryClean(sock, identity)
|
|
59
|
+
spawnSync('tmux', ['-S', sock, 'kill-session', '-t', identity], { stdio: 'ignore' })
|
|
60
|
+
const ls = spawnSync('tmux', ['-S', sock, 'list-sessions', '-F', '#{session_name}'], { encoding: 'utf8' })
|
|
61
|
+
if (!(ls.stdout ?? '').trim()) {
|
|
62
|
+
spawnSync('tmux', ['-S', sock, 'kill-server'], { stdio: 'ignore' })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
/**
|
|
43
67
|
* Build the always-on LaunchSpec for an infra peer, reading intelligence from the
|
|
44
68
|
* local peer-profile.json. launchd sets WorkingDirectory = peer cwd, so that file
|
|
@@ -159,6 +183,12 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
|
|
|
159
183
|
})
|
|
160
184
|
interrupt = null
|
|
161
185
|
}
|
|
186
|
+
// Signal-initiated exit (bootout / shutdown / kickstart -k) tears the session
|
|
187
|
+
// down WITH this watcher — without it the detached tmux poller outlived bootout
|
|
188
|
+
// holding stale in-memory state (см. teardownAlwaysOnSession). A natural session
|
|
189
|
+
// death (stop=false) skips this: there is nothing to tear down, exit 0 →
|
|
190
|
+
// KeepAlive respawns a fresh bring-up.
|
|
191
|
+
if (stop) teardownAlwaysOnSession(sock, identity)
|
|
162
192
|
return 0
|
|
163
193
|
}
|
|
164
194
|
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -380,6 +380,44 @@ export function writeTopic(cfg: LifecycleConfig, identity: string, topic: string
|
|
|
380
380
|
}
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Purge EVERY identity-keyed lifecycle artifact of `<identity>` from stateDir:
|
|
385
|
+
* the marker files (`.stopped` / `.idle-reaped` / `.deaths` / `.topic` /
|
|
386
|
+
* `.new-eager` / `.ephemeral-armed`), the supervise `.session`, the `.wake.lock`
|
|
387
|
+
* and the M3 `.queue/` dir — everything matching `<identity>.*` (the dot
|
|
388
|
+
* delimiter keeps `claude-bob` from ever matching `claude-bob2.*`).
|
|
389
|
+
*
|
|
390
|
+
* Consumer: `iapeer remove` (live defect, boris 10.06 cutover): a removed peer's
|
|
391
|
+
* stale `.stopped`/`.idle-reaped` survived in state/lifecycle, and a NEWBORN peer
|
|
392
|
+
* REUSING the personality inherited the dead namesake's parking — the daemon
|
|
393
|
+
* refused to wake it (`mode=refused cause=stopped`). Identity-keyed state must
|
|
394
|
+
* die with the registry record. Deliberately NOT called at birth: provision runs
|
|
395
|
+
* on EXISTING peers too (init re-runs), and purging there would erase a parked
|
|
396
|
+
* peer's `.idle-reaped` → its next wake comes up FRESH instead of RESUME
|
|
397
|
+
* (violates «на resume нет потери контекста»).
|
|
398
|
+
*
|
|
399
|
+
* Returns the removed entry names (for the verb's output); never throws.
|
|
400
|
+
*/
|
|
401
|
+
export function purgeIdentityState(cfg: LifecycleConfig, identity: string): string[] {
|
|
402
|
+
const removed: string[] = []
|
|
403
|
+
let entries: string[]
|
|
404
|
+
try {
|
|
405
|
+
entries = readdirSync(cfg.stateDir)
|
|
406
|
+
} catch {
|
|
407
|
+
return removed // no state dir → nothing to purge
|
|
408
|
+
}
|
|
409
|
+
for (const name of entries) {
|
|
410
|
+
if (!name.startsWith(`${identity}.`)) continue
|
|
411
|
+
try {
|
|
412
|
+
rmSync(join(cfg.stateDir, name), { recursive: true, force: true })
|
|
413
|
+
removed.push(name)
|
|
414
|
+
} catch {
|
|
415
|
+
/* best-effort — a locked/vanished entry must not fail the remove */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return removed
|
|
419
|
+
}
|
|
420
|
+
|
|
383
421
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
384
422
|
// resolveWakeMode — the resume-vs-fresh decision (TARGET redesign). The DAEMON
|
|
385
423
|
// decides by the DEATH CAUSE it tracks (.idle-reaped marker), plus peer-type /
|