@agfpd/iapeer 0.2.49 → 0.2.51

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.49",
3
+ "version": "0.2.51",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,10 +7,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
7
7
  import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
8
  import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
- import { addRuntime, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
10
+ import { addRuntime, compactPeer, defaultRuntime, formatListTable, listPeers, newPeer, parseArgs, removePeerCli, runCli, sendMessage, startPeer, stopPeer } from './index.ts'
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
12
  import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
+ import { ok } from '../core/errors.ts'
14
15
 
15
16
  let root: string
16
17
  let laDir: string
@@ -452,6 +453,58 @@ describe('new (unconditional fresh restart)', () => {
452
453
  })
453
454
  })
454
455
 
456
+ // ─── compact-resume — control addresses the dialogue, not just a live session ──
457
+
458
+ describe('compact (resume sleeping dialogue)', () => {
459
+ test('clean idle-reaped asleep peer → explicit resume wake, then /compact control', async () => {
460
+ await register('sleepy', 'codex')
461
+ const e = env()
462
+ const cfg = loadLifecycleConfig(e)
463
+ setIdleReaped(cfg, 'codex-sleepy')
464
+ const wakeCalls: unknown[] = []
465
+ const controlCalls: unknown[] = []
466
+ const o = await compactPeer('sleepy', 'codex', {
467
+ env: e,
468
+ wakeFn: async args => {
469
+ wakeCalls.push(args)
470
+ return { status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }
471
+ },
472
+ controlFn: (personality, runtime, command) => {
473
+ controlCalls.push({ personality, runtime, command })
474
+ return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
475
+ },
476
+ })
477
+ expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
478
+ expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
479
+ expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
480
+ expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
481
+ })
482
+
483
+ test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
484
+ await register('freshy', 'codex')
485
+ let woke = false
486
+ const o = await compactPeer('freshy', 'codex', {
487
+ env: env(),
488
+ wakeFn: async () => ((woke = true), { status: 'READY', woke: true }),
489
+ })
490
+ expect(o.action).toBe('nothing-to-compact')
491
+ expect(o.reason).toContain('nothing to compact')
492
+ expect(woke).toBe(false)
493
+ })
494
+
495
+ test('idle marker but no transcript/session to resume → nothing-to-compact (not fresh)', async () => {
496
+ await register('empty', 'codex')
497
+ const e = env()
498
+ setIdleReaped(loadLifecycleConfig(e), 'codex-empty')
499
+ const o = await compactPeer('empty', 'codex', {
500
+ env: e,
501
+ wakeFn: async args => ({ status: 'FAILED', woke: false, runtime: args.runtime, reason: 'no codex session to resume' }),
502
+ })
503
+ expect(o.action).toBe('nothing-to-compact')
504
+ expect(o.reason).toContain('nothing to compact')
505
+ })
506
+ })
507
+
455
508
  // ─── add-runtime / default-runtime — the fleet-switch levers (codex-parity) ───
456
509
 
457
510
  describe('add-runtime / default-runtime (fleet-switch levers)', () => {
package/src/cli/index.ts CHANGED
@@ -31,10 +31,12 @@ import {
31
31
  clearNewEager,
32
32
  clearStopped,
33
33
  folderLaunch,
34
+ hasIdleReaped,
34
35
  isLaunchdManaged,
35
36
  isEphemeralPeer,
36
37
  isStopped,
37
38
  killSession,
39
+ lastActiveRuntime,
38
40
  loadLifecycleConfig,
39
41
  purgeIdentityState,
40
42
  removeSessionState,
@@ -503,6 +505,95 @@ export async function newPeer(
503
505
  return { personality, runtime: rt, action: 'fresh' }
504
506
  }
505
507
 
508
+ export interface CompactPeerOutcome {
509
+ personality: string
510
+ runtime: string
511
+ action: 'compacted' | 'nothing-to-compact' | 'refused-foreign-launchd' | 'refused-infra' | 'refused-undeclared-runtime' | 'failed'
512
+ woke?: boolean
513
+ reason?: string
514
+ }
515
+
516
+ /**
517
+ * compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
518
+ * session. A live target receives the in-session `/compact` control as before. A
519
+ * cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
520
+ * A crashed / never-run / non-resumable target returns an honest "nothing to compact"
521
+ * instead of starting a fresh empty session and compacting that.
522
+ */
523
+ export async function compactPeer(
524
+ personality: string,
525
+ runtime: string | undefined,
526
+ opts: CliEnvOptions & {
527
+ wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
528
+ controlFn?: typeof routeControl
529
+ } = {},
530
+ ): Promise<CompactPeerOutcome> {
531
+ const env = opts.env ?? process.env
532
+ const cfg = loadLifecycleConfig(env)
533
+ const peer = findPeer(readPeersIndex({ env }), personality)
534
+ if (!peer) throw new Error(`peer "${personality}" is not registered`)
535
+ if (isForeignLaunchd(personality, env)) {
536
+ return {
537
+ personality,
538
+ runtime: peer.runtime,
539
+ action: 'refused-foreign-launchd',
540
+ reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation does not control it`,
541
+ }
542
+ }
543
+ if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
544
+ if (runtime && peer.runtime !== runtime && !peer.runtimes.includes(runtime as Runtime)) {
545
+ return {
546
+ personality,
547
+ runtime,
548
+ action: 'refused-undeclared-runtime',
549
+ reason: `peer "${personality}" does not declare runtime "${runtime}" (declared: ${peer.runtimes.join(', ')})`,
550
+ }
551
+ }
552
+
553
+ const control = opts.controlFn ?? routeControl
554
+ const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
555
+ if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
556
+ const r = control(personality, runtime, { name: 'compact' })
557
+ if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
558
+ return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
559
+ }
560
+
561
+ const rt: Runtime = (runtime as Runtime | undefined) ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
562
+ if (!peer.runtimes.includes(rt)) {
563
+ return {
564
+ personality,
565
+ runtime: rt,
566
+ action: 'refused-undeclared-runtime',
567
+ reason: `peer "${personality}" does not declare runtime "${rt}" (declared: ${peer.runtimes.join(', ')})`,
568
+ }
569
+ }
570
+ if (isInfraRuntime(rt)) {
571
+ return {
572
+ personality,
573
+ runtime: rt,
574
+ action: 'refused-infra',
575
+ reason: `infra runtime "${rt}" is launchd-held and has no compactable TUI dialogue`,
576
+ }
577
+ }
578
+ const identity = buildProcessAddress(rt, personality)
579
+ if (!hasIdleReaped(cfg, identity)) {
580
+ return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
581
+ }
582
+ const wake = opts.wakeFn ?? (args => wakeOrSpawn(args, { cfg, env }))
583
+ const w = await wake({ personality, runtime: rt, task: '', resume: true })
584
+ if (w.status !== 'READY') {
585
+ const reason = w.reason ?? 'wake failed'
586
+ if (/no .*session to resume|no transcript to resume|nothing to resume/i.test(reason)) {
587
+ return { personality, runtime: rt, action: 'nothing-to-compact', reason: 'context is fresh; nothing to compact' }
588
+ }
589
+ return { personality, runtime: rt, action: 'failed', reason }
590
+ }
591
+ clearIdleReaped(cfg, identity)
592
+ const r = control(personality, rt, { name: 'compact' })
593
+ if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
594
+ return { personality, runtime: rt, action: 'compacted', woke: true }
595
+ }
596
+
506
597
  // ─────────────────────────────────────────────────────────────────────────────
507
598
  // remove — delete a peer's record from the registry through the LOCKED writer
508
599
  // (registry.removePeer). Direct edits of peers-profiles.json are refused at
@@ -804,7 +895,7 @@ const USAGE = `usage: iapeer <verb> [args]
804
895
  default-runtime <runtime> (--peer <p> | --all) flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command
805
896
  attach <peer> [runtime] ensure-live + resume, then tmux attach
806
897
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
807
- compact <peer> [runtime] compact the peer's context (/compact)
898
+ compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
808
899
  self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
809
900
  self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
810
901
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
@@ -1521,10 +1612,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1521
1612
  killSession(sock, identity)
1522
1613
  return 0
1523
1614
  }
1524
- case 'interrupt':
1525
- case 'compact': {
1615
+ case 'interrupt': {
1526
1616
  // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
1527
- // turn (Escape) / compact context. UNCONDITIONAL — acts on the live session.
1617
+ // turn (Escape). UNCONDITIONAL — acts on the live session.
1528
1618
  if (!positionals[0]) return usage(errOut)
1529
1619
  const r = routeControl(positionals[0], positionals[1], { name: verb })
1530
1620
  if (!r.ok) {
@@ -1534,6 +1624,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1534
1624
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
1535
1625
  return 0
1536
1626
  }
1627
+ case 'compact': {
1628
+ // Dialogue control (UX directive 12.06): if the session is merely asleep
1629
+ // after a clean idle-reap, resume the same dialogue and compact it; if there
1630
+ // is no resumable dialogue, fail honestly instead of compacting a fresh one.
1631
+ if (!positionals[0]) return usage(errOut)
1632
+ const o = await compactPeer(positionals[0], positionals[1], { env })
1633
+ if (o.action === 'compacted') {
1634
+ out(`compact → ${o.personality} (${o.runtime})${o.woke ? ' after resume' : ''}\n`)
1635
+ return 0
1636
+ }
1637
+ errOut(`compact: ${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1638
+ return 1
1639
+ }
1537
1640
  case 'connect': {
1538
1641
  // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
1539
1642
  // `connect telegram <peer> [--token <t>]`. The human owes only the token;
@@ -69,7 +69,8 @@ describe('canary script (pure)', () => {
69
69
  // proven live); the SERVER's liveness decides, after a dismissal grace sleep.
70
70
  expect(s).not.toContain(`[ "$rc" -eq 0 ] || [ "$rc" -ge 128 ]; then exit 0`) // the v1 hole
71
71
  expect(s).toContain('sleep 2') // dismissal grace window
72
- expect(s).toContain(`has-session 2>/dev/null; then exit 0`) // server alive → nothing to record
72
+ expect(s).toContain(`kill -0 "$SPID" 2>/dev/null; then exit 0`) // original server alive → nothing to record
73
+ expect(s).toContain(`[ -z "$SPID" ]`) // fallback only when the original pid was unavailable
73
74
  expect(s).toContain('cause=server-vanished') // connection drop (SIGKILL/OOM class)
74
75
  expect(s).toContain('cause=signaled-server-gone') // rc=0: channel/client-TERM, server died
75
76
  expect(s).toContain('cause=client-killed-server-gone') // rc≥128: client took a hard kill
@@ -119,6 +120,7 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
119
120
  'notifier-canatear',
120
121
  'claude-canasweep',
121
122
  'claude-canaclient',
123
+ 'claude-canareplaced',
122
124
  ]
123
125
 
124
126
  afterAll(() => {
@@ -285,4 +287,44 @@ describe.if(tmuxAvailable)('canary live (sandbox tmux servers)', () => {
285
287
  },
286
288
  20000,
287
289
  )
290
+
291
+ test(
292
+ 'dirty server death followed by immediate replacement on the same socket still records the ORIGINAL death',
293
+ async () => {
294
+ const identity = 'claude-canareplaced'
295
+ const { sock, logDir } = bringUp(identity)
296
+ const pid = serverPid(sock)
297
+ expect(pid).toBeGreaterThan(0)
298
+ expect(ensureServerCanary({ identity, sock, exitLogDir: logDir })).toBe('spawned')
299
+ expect(await waitFor(() => canaryRunning(identity), 3000)).toBe(true)
300
+ await sleep(500)
301
+
302
+ process.kill(pid, 'SIGKILL')
303
+ // Reuse the same socket before the canary's 2s dismissal-grace window
304
+ // expires. The old socket-level probe treated this successor as "server
305
+ // alive" and lost the predecessor's death; the canary must key on SPID.
306
+ let started = false
307
+ for (let i = 0; i < 20; i++) {
308
+ const r = spawnSync('tmux', ['-S', sock, 'new-session', '-d', '-s', identity, 'sleep', '120'], {
309
+ stdio: 'ignore',
310
+ })
311
+ if (r.status === 0) {
312
+ started = true
313
+ break
314
+ }
315
+ await sleep(50)
316
+ }
317
+ expect(started).toBe(true)
318
+
319
+ const log = exitLogPath(logDir)
320
+ expect(await waitFor(() => existsSync(log) && readFileSync(log, 'utf8').includes('ev=server-exit'), 8000)).toBe(
321
+ true,
322
+ )
323
+ const line = readFileSync(log, 'utf8')
324
+ expect(line).toContain(`ev=server-exit identity=${identity}`)
325
+ expect(line).toContain(`server_pid=${pid}`)
326
+ expect(readdirSync(serverDeathsDir(logDir)).length).toBe(1)
327
+ },
328
+ 20000,
329
+ )
288
330
  })
@@ -19,14 +19,18 @@
19
19
  // sh recorder (`dismissCanary` → TERM → trap → silent exit). POSIX trap
20
20
  // semantics make this race-free: the trap runs before any recording.
21
21
  // • When wait-for returns — ANY code — the script sleeps 2 s (a concurrent
22
- // dismissal TERM wins here), then probes SERVER LIVENESS: alive → exit
23
- // silently (a lost canary is re-armed and logged by the supervise retrofit
24
- // within a tick); dead with nobody having dismissed us → the death is real
25
- // and unclaimed → ONE logfmt line `ev=server-exit` into exits.log (the
26
- // per-peer death-cause home, next to pane-died's `ev=session-exit`) + a
27
- // forensics snapshot (vm_stat / swap / top-RSS ps / fresh DiagnosticReports)
28
- // captured within seconds — the evidence the 60 s supervise tick can never
29
- // recover ("системных следов ноль" was the recurring investigation outcome).
22
+ // dismissal TERM wins here), then probes the ORIGINAL SERVER PID captured at
23
+ // arm time: still alive → exit silently (a lost canary is re-armed and logged
24
+ // by the supervise retrofit within a tick); gone with nobody having dismissed
25
+ // us → the death is real and unclaimed → ONE logfmt line `ev=server-exit`
26
+ // into exits.log (the per-peer death-cause home, next to pane-died's
27
+ // `ev=session-exit`) + a forensics snapshot (vm_stat / swap / top-RSS ps /
28
+ // fresh DiagnosticReports) captured within seconds — the evidence the 60 s
29
+ // supervise tick can never recover ("системных следов ноль" was the recurring
30
+ // investigation outcome). PID identity matters: a wake/attach can replace the
31
+ // tmux server on the same socket before the 2 s grace elapses; probing only
32
+ // the socket would mistake the successor for the dead original and lose the
33
+ // canary record (live canary-gap 11.06).
30
34
  // The raw wait_rc still ATTRIBUTES the death (cause=server-vanished /
31
35
  // signaled-server-gone / client-killed-server-gone).
32
36
  // Residual blind spot (structural): a killer that SIGKILLs the sh recorder
@@ -98,12 +102,16 @@ export function canaryScript(o: CanaryScriptOptions): string {
98
102
  // `rc=0 || rc>=128 → silent` was exactly that hole). v2 contract instead:
99
103
  // • deliberate teardowns DISMISS this sh (TERM → trap above) — the sleep
100
104
  // below gives a concurrently-delivered dismissal time to win;
101
- // • then the SERVER's liveness, not the exit code, decides: alive →
105
+ // • then the ORIGINAL SERVER PID's liveness, not the exit code, decides: alive →
102
106
  // nothing to record (a lost canary is re-armed and logged by the
103
- // supervise retrofit within a tick); dead and nobody dismissed us →
104
- // the death is real and unclaimed → record it.
107
+ // supervise retrofit within a tick); original server dead and nobody
108
+ // dismissed us → the death is real and unclaimed → record it. IMPORTANT:
109
+ // check the ORIGINAL SPID, not merely "some server answers on the same
110
+ // socket". A wake/attach can replace the server inside this 2 s window;
111
+ // socket-liveness would then silently swallow the predecessor's death.
105
112
  `sleep 2`,
106
- `if '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
113
+ `if [ -n "$SPID" ] && kill -0 "$SPID" 2>/dev/null; then exit 0; fi`,
114
+ `if [ -z "$SPID" ] && '${o.tmuxBin}' -S '${o.sock}' has-session 2>/dev/null; then exit 0; fi`,
107
115
  // The exit code still ATTRIBUTES the recorded death (raw wait_rc is kept):
108
116
  // rc=0 → signaled-server-gone (channel signal or client-TERM, server died)
109
117
  // rc≥128 → client-killed-server-gone (client took a non-TERM kill)