@agfpd/iapeer 0.2.48 → 0.2.50

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.48",
3
+ "version": "0.2.50",
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
@@ -20,7 +20,7 @@ import {
20
20
  type Runtime,
21
21
  } from '../core/constants.ts'
22
22
  import { IAPEER_VERSION } from '../core/version.ts'
23
- import { updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
23
+ import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../update/index.ts'
24
24
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
25
25
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
26
26
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
@@ -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,
@@ -63,6 +65,22 @@ import { readMemoryProvider } from '../status/index.ts'
63
65
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
64
66
  import { pluginLogsDir } from '../storage/index.ts'
65
67
 
68
+ function infraRecycleNote(infra: readonly { personality: string; state: string; detail?: string }[] | undefined): string {
69
+ if (!infra?.length) return ''
70
+ const touched = infra.filter(i => i.state === 'restarted').map(i => i.personality)
71
+ const failed = infra.filter(i => i.state === 'failed')
72
+ const passive = infra.filter(i => i.state !== 'restarted' && i.state !== 'failed')
73
+ const parts: string[] = []
74
+ if (touched.length) parts.push(`infra recycled: ${touched.join(', ')}`)
75
+ if (passive.length) parts.push(`infra ${passive.map(i => `${i.personality} ${i.state}`).join(', ')}`)
76
+ if (failed.length) parts.push(`infra FAILED: ${failed.map(i => `${i.personality}${i.detail ? ` (${i.detail})` : ''}`).join('; ')}`)
77
+ return parts.length ? `; ${parts.join('; ')}` : ''
78
+ }
79
+
80
+ function infraRecycleFailed(infra: readonly { state: string }[] | undefined): boolean {
81
+ return (infra ?? []).some(i => i.state === 'failed')
82
+ }
83
+
66
84
  // ─────────────────────────────────────────────────────────────────────────────
67
85
  // list — registry + per-runtime liveness (contract Примитивы §list)
68
86
  // ─────────────────────────────────────────────────────────────────────────────
@@ -487,6 +505,95 @@ export async function newPeer(
487
505
  return { personality, runtime: rt, action: 'fresh' }
488
506
  }
489
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
+
490
597
  // ─────────────────────────────────────────────────────────────────────────────
491
598
  // remove — delete a peer's record from the registry through the LOCKED writer
492
599
  // (registry.removePeer). Direct edits of peers-profiles.json are refused at
@@ -760,8 +867,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
760
867
 
761
868
  const USAGE = `usage: iapeer <verb> [args]
762
869
  install build binary + global scaffold + daemon plist (one bootstrap)
763
- update [version] [--force] pull latest (or an exact version) of @agfpd/iapeer from npm + restart the daemon
764
- rollback revert to the previous binary (.prev) + restart the daemon
870
+ update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
871
+ rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
765
872
  version | --version | -v print the installed binary's version
766
873
  help | --help | -h print this usage (works appended to any verb; executes nothing)
767
874
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
@@ -788,7 +895,7 @@ const USAGE = `usage: iapeer <verb> [args]
788
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
789
896
  attach <peer> [runtime] ensure-live + resume, then tmux attach
790
897
  interrupt <peer> [runtime] interrupt the current turn (Escape) — context intact
791
- compact <peer> [runtime] compact the peer's context (/compact)
898
+ compact <peer> [runtime] compact the peer's dialogue (/compact); resumes clean-asleep first
792
899
  self-fresh (agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh
793
900
  self-done (agent self-call, ephemeral) silent finish: arm own quiet-reap, wake no one
794
901
  native-memory <off|on> (--peer <p> | --all) gate/restore runtimes' native memory (canonized lever; контракт «Слот памяти»)
@@ -1324,7 +1431,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1324
1431
  `roll back now: iapeer rollback\n`)
1325
1432
  return 1
1326
1433
  }
1327
- out(`updated ${r.from} → ${r.to}; daemon restarted and healthy\n`)
1434
+ const note = infraRecycleNote(r.infra)
1435
+ if (infraRecycleFailed(r.infra)) {
1436
+ errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1437
+ return 1
1438
+ }
1439
+ out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1328
1440
  return 0
1329
1441
  }
1330
1442
  const daemonNote =
@@ -1333,8 +1445,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1333
1445
  : r.daemon === 'failed'
1334
1446
  ? `WARNING — ${r.reason}; roll back with: iapeer rollback`
1335
1447
  : String(r.daemon)
1336
- out(`updated ${r.from} → ${r.to}; ${daemonNote}\n`)
1337
- return r.daemon === 'failed' ? 1 : 0
1448
+ const note = infraRecycleNote(r.infra)
1449
+ const failed = r.daemon === 'failed' || infraRecycleFailed(r.infra)
1450
+ ;(failed ? errOut : out)(`updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
1451
+ return failed ? 1 : 0
1338
1452
  }
1339
1453
  case 'rollback': {
1340
1454
  // Recovery: restore the .prev binary kept by the last install, restart the
@@ -1348,23 +1462,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1348
1462
  return 1
1349
1463
  }
1350
1464
  const restart = cycleDaemon(env)
1465
+ const infra = recycleFoundationOwnedInfraJobs(env)
1466
+ const infraNote = infraRecycleNote(infra)
1467
+ const infraFailed = infraRecycleFailed(infra)
1351
1468
  if (restart.state === 'restarted') {
1352
1469
  const h = await waitForDaemonHealthy({ env })
1353
- out(
1354
- h.healthy
1355
- ? `rolled back to the previous binary; daemon restarted and healthy\n`
1356
- : `rolled back, but the daemon is NOT healthy after restart (${h.detail})\n`,
1357
- )
1358
- return h.healthy ? 0 : 1
1470
+ const msg = h.healthy
1471
+ ? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
1472
+ : `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
1473
+ ;(h.healthy && !infraFailed ? out : errOut)(msg)
1474
+ return h.healthy && !infraFailed ? 0 : 1
1359
1475
  }
1360
- out(
1476
+ const msg =
1361
1477
  `rolled back to the previous binary; ${
1362
1478
  restart.state === 'not-loaded'
1363
1479
  ? 'daemon not loaded — previous binary will be used on next start'
1364
1480
  : `daemon restart ${restart.state}${restart.detail ? ` (${restart.detail})` : ''}`
1365
- }\n`,
1366
- )
1367
- return restart.state === 'failed' ? 1 : 0
1481
+ }${infraNote}\n`
1482
+ const failed = restart.state === 'failed' || infraFailed
1483
+ ;(failed ? errOut : out)(msg)
1484
+ return failed ? 1 : 0
1368
1485
  }
1369
1486
  case 'install': {
1370
1487
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
@@ -1495,10 +1612,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1495
1612
  killSession(sock, identity)
1496
1613
  return 0
1497
1614
  }
1498
- case 'interrupt':
1499
- case 'compact': {
1615
+ case 'interrupt': {
1500
1616
  // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
1501
- // turn (Escape) / compact context. UNCONDITIONAL — acts on the live session.
1617
+ // turn (Escape). UNCONDITIONAL — acts on the live session.
1502
1618
  if (!positionals[0]) return usage(errOut)
1503
1619
  const r = routeControl(positionals[0], positionals[1], { name: verb })
1504
1620
  if (!r.ok) {
@@ -1508,6 +1624,19 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1508
1624
  out(`${verb} → ${r.value.controlled.personality} (${r.value.controlled.runtime})\n`)
1509
1625
  return 0
1510
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
+ }
1511
1640
  case 'connect': {
1512
1641
  // Per-peer channel attachment in ONE flow (design «Onboard костяка» §(в)):
1513
1642
  // `connect telegram <peer> [--token <t>]`. The human owes only the token;
@@ -61,6 +61,8 @@ export {
61
61
  isFoundationOwnedPlist,
62
62
  launchctlBootstrap,
63
63
  bootstrapJobCore,
64
+ cycleLaunchdJob,
65
+ cycleLaunchdJobCore,
64
66
  cycleDaemon,
65
67
  cycleDaemonCore,
66
68
  resolveExecutable,
@@ -75,6 +77,8 @@ export type {
75
77
  BootstrapCoreResult,
76
78
  DaemonRestartResult,
77
79
  DaemonRestartState,
80
+ LaunchdJobCycleResult,
81
+ LaunchdJobCycleState,
78
82
  LaunchctlRunner,
79
83
  } from './launchd.ts'
80
84
 
@@ -298,38 +298,41 @@ export function bootstrapJobCore(
298
298
  }
299
299
  }
300
300
 
301
- export type DaemonRestartState =
302
- | 'restarted' // bootout + bootstrap succeeded → the daemon runs the freshly-installed binary
303
- | 'not-loaded' // com.agfpd.iapeer is not in the gui domain → nothing to restart (new binary
304
- // will be used whenever it IS next started; nothing to do here)
301
+ export type LaunchdJobCycleState =
302
+ | 'restarted' // bootout + bootstrap succeeded → the job runs with the currently-referenced binaries
303
+ | 'not-loaded' // the label is not in the gui domain → nothing to restart (next start will use the current plist/binaries)
305
304
  | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
306
305
  | 'failed' // bootout/bootstrap failed — the result carries the manual rescue recipe
307
306
 
308
- export interface DaemonRestartResult {
309
- state: DaemonRestartState
307
+ export interface LaunchdJobCycleResult {
308
+ state: LaunchdJobCycleState
310
309
  detail?: string
311
310
  }
312
311
 
312
+ export type DaemonRestartState = LaunchdJobCycleState
313
+ export interface DaemonRestartResult extends LaunchdJobCycleResult {}
314
+
313
315
  /**
314
- * DI core of cycleDaemon (unit-testable without launchctl): bootout → wait-for-gone
316
+ * DI core of cycleLaunchdJob (unit-testable without launchctl): bootout → wait-for-gone
315
317
  * → bootstrap, composed on bootstrapJobCore's undead-job-safe wait/backoff.
316
318
  * Disambiguation of bootstrapJobCore's 'already-loaded': at attempts=0 it means the
317
319
  * job NEVER unloaded after our bootout (undead beyond budget) → FAIL loud with the
318
320
  * manual recipe; at attempts>0 it means a retry found the job loaded (our previous
319
321
  * attempt half-succeeded or KeepAlive raced us in) → the job IS up → success.
320
322
  */
321
- export function cycleDaemonCore(
323
+ export function cycleLaunchdJobCore(
322
324
  uid: string,
325
+ label: string,
323
326
  plistPath: string,
324
327
  deps: BootstrapCoreDeps,
325
- ): DaemonRestartResult {
326
- const listed = () => deps.run(['print', `gui/${uid}/${DAEMON_PLIST_LABEL}`]).status === 0
328
+ ): LaunchdJobCycleResult {
329
+ const listed = () => deps.run(['print', `gui/${uid}/${label}`]).status === 0
327
330
  if (!listed()) return { state: 'not-loaded' }
328
- const bo = deps.run(['bootout', `gui/${uid}/${DAEMON_PLIST_LABEL}`])
331
+ const bo = deps.run(['bootout', `gui/${uid}/${label}`])
329
332
  if (bo.status !== 0) {
330
333
  return { state: 'failed', detail: `launchctl bootout failed: ${bo.stderr.trim() || `exit ${bo.status}`}` }
331
334
  }
332
- const core = bootstrapJobCore(uid, DAEMON_PLIST_LABEL, plistPath, deps)
335
+ const core = bootstrapJobCore(uid, label, plistPath, deps)
333
336
  if (core.state === 'loaded' || (core.state === 'already-loaded' && core.attempts > 0)) {
334
337
  return { state: 'restarted' }
335
338
  }
@@ -338,7 +341,7 @@ export function cycleDaemonCore(
338
341
  state: 'failed',
339
342
  detail:
340
343
  `job never unloaded after bootout (undead beyond budget) — manual rescue: ` +
341
- `launchctl bootout gui/${uid}/${DAEMON_PLIST_LABEL} && launchctl bootstrap gui/${uid} ${plistPath}`,
344
+ `launchctl bootout gui/${uid}/${label} && launchctl bootstrap gui/${uid} ${plistPath}`,
342
345
  }
343
346
  }
344
347
  return {
@@ -347,6 +350,15 @@ export function cycleDaemonCore(
347
350
  }
348
351
  }
349
352
 
353
+ /** Back-compat wrapper for the foundation daemon's label. */
354
+ export function cycleDaemonCore(
355
+ uid: string,
356
+ plistPath: string,
357
+ deps: BootstrapCoreDeps,
358
+ ): DaemonRestartResult {
359
+ return cycleLaunchdJobCore(uid, DAEMON_PLIST_LABEL, plistPath, deps)
360
+ }
361
+
350
362
  /**
351
363
  * Restart the foundation daemon (`com.agfpd.iapeer`) onto the binary currently on
352
364
  * disk — `launchctl bootout` + `bootstrap`, DELIBERATELY NOT `kickstart -k`.
@@ -368,12 +380,26 @@ export function cycleDaemonCore(
368
380
  * version-stable (write-if-changed) and is NOT rewritten here. Sandbox-safe.
369
381
  */
370
382
  export function cycleDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
383
+ return cycleLaunchdJob(DAEMON_PLIST_LABEL, join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`), env)
384
+ }
385
+
386
+ /**
387
+ * Restart any launchd job onto the executable(s) currently referenced by its plist:
388
+ * bootout → bootstrap, never kickstart. Used by the daemon update path AND by
389
+ * foundation-owned infra peers (`com.iapeer.*`) whose ProgramArguments also point at
390
+ * the installed `iapeer` binary. The caller owns any fleet/ownership guard; this
391
+ * primitive only cycles the concrete label/plist pair. Sandbox-safe.
392
+ */
393
+ export function cycleLaunchdJob(
394
+ label: string,
395
+ plistPath: string,
396
+ env: NodeJS.ProcessEnv = process.env,
397
+ ): LaunchdJobCycleResult {
371
398
  if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
372
399
  return { state: 'skipped-sandbox' }
373
400
  }
374
401
  const uid = currentUid()
375
- const plistPath = join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
376
- return cycleDaemonCore(uid, plistPath, {
402
+ return cycleLaunchdJobCore(uid, label, plistPath, {
377
403
  run: args => {
378
404
  const r = spawnSync('launchctl', args, { encoding: 'utf8' })
379
405
  return { status: r.status, stderr: r.stderr ?? '' }
@@ -11,11 +11,14 @@
11
11
  // and run ITS own source installer. DELIBERATELY NOT `npx … install` (see
12
12
  // defaultRunInstall for why npx is unsafe here).
13
13
  // 4. cycle com.agfpd.iapeer IF loaded (bootout+bootstrap) (activate the new binary; kickstart hits the LWCR EX_CONFIG(78) bomb)
14
+ // 5. recycle loaded FOUNDATION-OWNED infra jobs (com.iapeer.* with the sentinel)
15
+ // because their plists also run the just-replaced `iapeer run-infra` binary.
14
16
  //
15
- // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
16
- // touches the plist (version-stable launch path) and never touches other packages
17
- // on the host (telegram-runtime / notifier-runtime / MergeMind / the peer fleet) —
18
- // each of those has its own version and its own update.
17
+ // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon + launchd
18
+ // registrations of foundation-owned infra jobs that point at that binary). It never
19
+ // rewrites those plists, never updates runtime packages (telegram-runtime /
20
+ // notifier-runtime / MergeMind), and never touches foreign / persistent-peer fleet
21
+ // plists — only sentinel-marked foundation-owned com.iapeer.* jobs are recycled.
19
22
  //
20
23
  // updateIapeer takes its three side-effects as INJECTED deps so the version-gate is
21
24
  // unit-testable with no network and no launchctl; the defaults are the real impls.
@@ -25,9 +28,19 @@ import { mkdtempSync, readdirSync, rmSync } from 'fs'
25
28
  import { connect } from 'net'
26
29
  import { tmpdir } from 'os'
27
30
  import { join } from 'path'
31
+ import { isInfraRuntime } from '../core/constants.ts'
28
32
  import { IapError } from '../core/errors.ts'
29
33
  import { IAPEER_VERSION } from '../core/version.ts'
30
- import { cycleDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
34
+ import { readPeersIndex } from '../registry/index.ts'
35
+ import {
36
+ cycleDaemon,
37
+ cycleLaunchdJob,
38
+ isFoundationOwnedPlist,
39
+ launchdLabel,
40
+ launchdPlistPath,
41
+ type DaemonRestartResult,
42
+ type LaunchdJobCycleResult,
43
+ } from '../launch/launchd.ts'
31
44
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
32
45
 
33
46
  /** The npm package the foundation publishes / updates from. */
@@ -45,11 +58,22 @@ export interface UpdateDeps {
45
58
  /** Resolve a spec ('latest' or an exact 'X.Y.Z') to the concrete published version it
46
59
  * names, or null on a miss / npm error (default: `npm view @agfpd/iapeer@<spec> version`). */
47
60
  resolveVersion?: (spec: string, env: NodeJS.ProcessEnv) => string | null
48
- /** Pull + rebuild the binary for `version` (default: `npx @agfpd/iapeer@<v> install`).
61
+ /** Pull + rebuild the binary for `version` (default: npm pack → extract → source install).
49
62
  * Returns true on success. */
50
63
  runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
51
64
  /** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
52
65
  restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
66
+ /** Re-register loaded foundation-owned infra launchd jobs whose plists run the
67
+ * installed iapeer binary (default: registry scan + sentinel guard). */
68
+ recycleInfraJobs?: (env: NodeJS.ProcessEnv) => InfraJobRecycleResult[]
69
+ }
70
+
71
+ export interface InfraJobRecycleResult {
72
+ personality: string
73
+ runtime: string
74
+ label: string
75
+ state: LaunchdJobCycleResult['state']
76
+ detail?: string
53
77
  }
54
78
 
55
79
  export interface UpdateResult {
@@ -62,6 +86,8 @@ export interface UpdateResult {
62
86
  to?: string
63
87
  /** What happened to the live daemon (only on 'updated'). */
64
88
  daemon?: DaemonRestartResult['state']
89
+ /** Foundation-owned infra jobs recycled after the binary swap (only on 'updated'). */
90
+ infra?: InfraJobRecycleResult[]
65
91
  /** Human reason on 'failed' / extra detail on a daemon restart hiccup. */
66
92
  reason?: string
67
93
  }
@@ -195,6 +221,30 @@ function defaultRunInstall(version: string, env: NodeJS.ProcessEnv): boolean {
195
221
  }
196
222
  }
197
223
 
224
+ /**
225
+ * Re-register every loaded FOUNDATION-owned infra peer job after the installed
226
+ * iapeer binary is replaced. These plists run `iapeer run-infra <peer> <runtime>`;
227
+ * if launchd keeps the old managed launch constraint, a later KeepAlive respawn can
228
+ * hit the same EX_CONFIG(78) LWCR crash-loop that bit the daemon. Full bootout →
229
+ * bootstrap clears the registration. Foreign / persistent-peer plists are skipped
230
+ * by the sentinel guard, preserving H4.
231
+ */
232
+ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process.env): InfraJobRecycleResult[] {
233
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return []
234
+ const out: InfraJobRecycleResult[] = []
235
+ const index = readPeersIndex({ env })
236
+ for (const peer of index.peers) {
237
+ const runtime = (isInfraRuntime(peer.runtime) ? peer.runtime : peer.runtimes.find(isInfraRuntime)) ?? ''
238
+ if (!runtime) continue
239
+ const plist = launchdPlistPath(peer.personality, env)
240
+ if (!isFoundationOwnedPlist(plist)) continue
241
+ const label = launchdLabel(peer.personality)
242
+ const r = cycleLaunchdJob(label, plist, env)
243
+ out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
244
+ }
245
+ return out
246
+ }
247
+
198
248
  /**
199
249
  * Update the foundation to the latest published version (cloud-only) and restart
200
250
  * the daemon onto it. Idempotent + version-gated: a no-op "already-latest" when the
@@ -242,12 +292,18 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
242
292
 
243
293
  const restart = deps.restartDaemon ?? cycleDaemon
244
294
  const d = restart(env)
295
+ const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
296
+ // If the daemon re-registration itself failed, do NOT widen the blast radius by
297
+ // cycling infra jobs onto a binary whose daemon could not come up. Rollback will
298
+ // re-cycle them after restoring `.prev`.
299
+ const infra = d.state === 'failed' ? [] : recycleInfra(env)
245
300
  return {
246
301
  status: 'updated',
247
302
  from,
248
303
  to: desired,
249
304
  latest: desired,
250
305
  daemon: d.state,
306
+ infra,
251
307
  reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
252
308
  }
253
309
  }
@@ -4,7 +4,7 @@
4
4
  // exercised too (a test must never npx-install over the prod binary).
5
5
 
6
6
  import { describe, expect, test } from 'bun:test'
7
- import { IAPEER_VERSION, updateIapeer, waitForDaemonHealthy } from '../index.ts'
7
+ import { IAPEER_VERSION, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } from '../index.ts'
8
8
  import type { DaemonRestartResult } from '../launch/launchd.ts'
9
9
 
10
10
  const restarted = (): DaemonRestartResult => ({ state: 'restarted' })
@@ -17,9 +17,10 @@ function harness(opts: {
17
17
  target?: string
18
18
  installOk?: boolean
19
19
  restart?: DaemonRestartResult
20
+ recycle?: ReturnType<typeof recycleFoundationOwnedInfraJobs>
20
21
  force?: boolean
21
22
  }) {
22
- const calls = { install: [] as string[], restart: 0, resolved: [] as string[] }
23
+ const calls = { install: [] as string[], restart: 0, recycle: 0, resolved: [] as string[] }
23
24
  const result = updateIapeer({
24
25
  env: { IAPEER_TEST_SANDBOX: '1' },
25
26
  force: opts.force,
@@ -37,6 +38,10 @@ function harness(opts: {
37
38
  calls.restart++
38
39
  return opts.restart ?? restarted()
39
40
  },
41
+ recycleInfraJobs: () => {
42
+ calls.recycle++
43
+ return opts.recycle ?? []
44
+ },
40
45
  })
41
46
  return { result, calls }
42
47
  }
@@ -49,6 +54,7 @@ describe('updateIapeer — version gate', () => {
49
54
  expect(result.latest).toBe('0.2.2')
50
55
  expect(calls.install).toEqual([])
51
56
  expect(calls.restart).toBe(0)
57
+ expect(calls.recycle).toBe(0)
52
58
  })
53
59
 
54
60
  test('--force reinstalls + restarts even when already latest', () => {
@@ -65,8 +71,18 @@ describe('updateIapeer — version gate', () => {
65
71
  expect(result.from).toBe('0.2.2')
66
72
  expect(result.to).toBe('0.3.0')
67
73
  expect(result.daemon).toBe('restarted')
74
+ expect(result.infra).toEqual([])
68
75
  expect(calls.install).toEqual(['0.3.0']) // installs latest, not current
69
76
  expect(calls.restart).toBe(1)
77
+ expect(calls.recycle).toBe(1)
78
+ })
79
+
80
+ test('after binary install, loaded foundation-owned infra jobs are recycled too', () => {
81
+ const recycle = [{ personality: 'timer', runtime: 'notifier', label: 'com.iapeer.timer', state: 'restarted' as const }]
82
+ const { result, calls } = harness({ current: '0.2.2', latest: '0.3.0', recycle })
83
+ expect(result.status).toBe('updated')
84
+ expect(result.infra).toEqual(recycle)
85
+ expect(calls.recycle).toBe(1)
70
86
  })
71
87
 
72
88
  test('no target → resolves the "latest" spec', () => {
@@ -125,7 +141,7 @@ describe('updateIapeer — failure paths', () => {
125
141
  })
126
142
 
127
143
  test('binary updated but restart failed → updated with a warning reason', () => {
128
- const { result } = harness({
144
+ const { result, calls } = harness({
129
145
  current: '0.2.2',
130
146
  latest: '0.3.0',
131
147
  restart: { state: 'failed', detail: 'kickstart exit 1' },
@@ -133,6 +149,7 @@ describe('updateIapeer — failure paths', () => {
133
149
  expect(result.status).toBe('updated')
134
150
  expect(result.daemon).toBe('failed')
135
151
  expect(result.reason).toMatch(/restart failed.*kickstart exit 1/i)
152
+ expect(calls.recycle).toBe(0)
136
153
  })
137
154
  })
138
155