@agfpd/iapeer 0.2.22 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.22",
3
+ "version": "0.2.23",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return { personality, action: 'removed', cwd: peer.cwd }
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)) {
@@ -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 /