@agfpd/iapeer 0.2.47 → 0.2.49

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.47",
3
+ "version": "0.2.49",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
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'
@@ -63,6 +63,22 @@ import { readMemoryProvider } from '../status/index.ts'
63
63
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
64
64
  import { pluginLogsDir } from '../storage/index.ts'
65
65
 
66
+ function infraRecycleNote(infra: readonly { personality: string; state: string; detail?: string }[] | undefined): string {
67
+ if (!infra?.length) return ''
68
+ const touched = infra.filter(i => i.state === 'restarted').map(i => i.personality)
69
+ const failed = infra.filter(i => i.state === 'failed')
70
+ const passive = infra.filter(i => i.state !== 'restarted' && i.state !== 'failed')
71
+ const parts: string[] = []
72
+ if (touched.length) parts.push(`infra recycled: ${touched.join(', ')}`)
73
+ if (passive.length) parts.push(`infra ${passive.map(i => `${i.personality} ${i.state}`).join(', ')}`)
74
+ if (failed.length) parts.push(`infra FAILED: ${failed.map(i => `${i.personality}${i.detail ? ` (${i.detail})` : ''}`).join('; ')}`)
75
+ return parts.length ? `; ${parts.join('; ')}` : ''
76
+ }
77
+
78
+ function infraRecycleFailed(infra: readonly { state: string }[] | undefined): boolean {
79
+ return (infra ?? []).some(i => i.state === 'failed')
80
+ }
81
+
66
82
  // ─────────────────────────────────────────────────────────────────────────────
67
83
  // list — registry + per-runtime liveness (contract Примитивы §list)
68
84
  // ─────────────────────────────────────────────────────────────────────────────
@@ -760,8 +776,8 @@ export function parseArgs(argv: string[]): { positionals: string[]; flags: Recor
760
776
 
761
777
  const USAGE = `usage: iapeer <verb> [args]
762
778
  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
779
+ update [version] [--force] pull latest (or exact) @agfpd/iapeer from npm + restart daemon + recycle owned infra jobs
780
+ rollback revert to the previous binary (.prev) + restart daemon + recycle owned infra jobs
765
781
  version | --version | -v print the installed binary's version
766
782
  help | --help | -h print this usage (works appended to any verb; executes nothing)
767
783
  daemon [--install-plist] run the host-wide HTTP-MCP router (launchd-held)
@@ -1324,7 +1340,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1324
1340
  `roll back now: iapeer rollback\n`)
1325
1341
  return 1
1326
1342
  }
1327
- out(`updated ${r.from} → ${r.to}; daemon restarted and healthy\n`)
1343
+ const note = infraRecycleNote(r.infra)
1344
+ if (infraRecycleFailed(r.infra)) {
1345
+ errOut(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1346
+ return 1
1347
+ }
1348
+ out(`updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
1328
1349
  return 0
1329
1350
  }
1330
1351
  const daemonNote =
@@ -1333,8 +1354,10 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1333
1354
  : r.daemon === 'failed'
1334
1355
  ? `WARNING — ${r.reason}; roll back with: iapeer rollback`
1335
1356
  : String(r.daemon)
1336
- out(`updated ${r.from} → ${r.to}; ${daemonNote}\n`)
1337
- return r.daemon === 'failed' ? 1 : 0
1357
+ const note = infraRecycleNote(r.infra)
1358
+ const failed = r.daemon === 'failed' || infraRecycleFailed(r.infra)
1359
+ ;(failed ? errOut : out)(`updated ${r.from} → ${r.to}; ${daemonNote}${note}\n`)
1360
+ return failed ? 1 : 0
1338
1361
  }
1339
1362
  case 'rollback': {
1340
1363
  // Recovery: restore the .prev binary kept by the last install, restart the
@@ -1348,23 +1371,26 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1348
1371
  return 1
1349
1372
  }
1350
1373
  const restart = cycleDaemon(env)
1374
+ const infra = recycleFoundationOwnedInfraJobs(env)
1375
+ const infraNote = infraRecycleNote(infra)
1376
+ const infraFailed = infraRecycleFailed(infra)
1351
1377
  if (restart.state === 'restarted') {
1352
1378
  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
1379
+ const msg = h.healthy
1380
+ ? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
1381
+ : `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
1382
+ ;(h.healthy && !infraFailed ? out : errOut)(msg)
1383
+ return h.healthy && !infraFailed ? 0 : 1
1359
1384
  }
1360
- out(
1385
+ const msg =
1361
1386
  `rolled back to the previous binary; ${
1362
1387
  restart.state === 'not-loaded'
1363
1388
  ? 'daemon not loaded — previous binary will be used on next start'
1364
1389
  : `daemon restart ${restart.state}${restart.detail ? ` (${restart.detail})` : ''}`
1365
- }\n`,
1366
- )
1367
- return restart.state === 'failed' ? 1 : 0
1390
+ }${infraNote}\n`
1391
+ const failed = restart.state === 'failed' || infraFailed
1392
+ ;(failed ? errOut : out)(msg)
1393
+ return failed ? 1 : 0
1368
1394
  }
1369
1395
  case 'install': {
1370
1396
  // UNIFIED foundation install (contract Установка §1 — "один npx ставит
@@ -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 ?? '' }
@@ -1251,20 +1251,29 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1251
1251
  * consumed BEFORE the relaunch so a relaunch failure does not loop on the same eager mark
1252
1252
  * (it then fresh-wakes lazily on its next message — branch 3a — never lost). NB: a /new'd
1253
1253
  * peer is expected to carry an initial_prompt; without one the first turn delivers nothing.
1254
+ *
1255
+ * Runtime is DELIBERATELY NOT pinned to the dead session's runtime: the relaunch passes
1256
+ * no runtime so resolveWakeRuntime falls through to the registry default (H5 — registry
1257
+ * is the only source of wake-runtime truth). Pinning broke the fleet-switch procedure
1258
+ * (`default-runtime <rt>` + `self-fresh`): the peer silently resurrected on the OLD
1259
+ * runtime, ignoring the just-flipped default (live incident 12.06, claude-iapeer). A
1260
+ * session deliberately living on a non-default runtime has explicit paths back (wake
1261
+ * with an explicit runtime); an owner /new returns the peer to its declared default.
1254
1262
  */
1255
1263
  export async function processEagerRelaunches(
1256
1264
  cfg: LifecycleConfig,
1257
1265
  outcomes: SuperviseOutcome[],
1258
- deps: WakeDeps = {},
1266
+ deps: WakeDeps & { wakeFn?: (args: WakeArgs, deps: WakeDeps) => Promise<WakeResult> } = {},
1259
1267
  ): Promise<WakeResult[]> {
1268
+ const wake = deps.wakeFn ?? wakeOrSpawn
1260
1269
  const results: WakeResult[] = []
1261
1270
  for (const o of outcomes) {
1262
- if (o.action !== 'needs-eager-fresh' || !o.personality || !o.runtime) continue
1271
+ if (o.action !== 'needs-eager-fresh' || !o.personality) continue
1263
1272
  clearNewEager(cfg, o.identity) // consume the eager mark — the relaunch is fresh by construction
1264
1273
  try {
1265
1274
  results.push(
1266
- await wakeOrSpawn(
1267
- { personality: o.personality, runtime: o.runtime, task: '', resume: false },
1275
+ await wake(
1276
+ { personality: o.personality, task: '', resume: false },
1268
1277
  { cfg, env: deps.env },
1269
1278
  ),
1270
1279
  )
@@ -621,6 +621,38 @@ describe('superviseTick death-cause accounting (TARGET)', () => {
621
621
  }
622
622
  })
623
623
 
624
+ test('eager relaunch does NOT pin the dead session runtime — re-resolves from the registry (H5 fleet-switch)', async () => {
625
+ // Live incident 12.06: `default-runtime codex` + `self-fresh` resurrected the peer
626
+ // on the OLD runtime — the relaunch passed the dead session's runtime EXPLICITLY,
627
+ // so resolveWakeRuntime never consulted the just-flipped registry default.
628
+ const { env, cfg, root, laDir } = deadSessionEnv('rt')
629
+ try {
630
+ setNewEager(cfg, 'claude-rt')
631
+ const outcomes = superviseTick(cfg, { env })
632
+ const o = outcomes.find(x => x.identity === 'claude-rt')
633
+ expect(o?.action).toBe('needs-eager-fresh')
634
+ const seen: Array<{ personality: string; runtime?: string; resume?: boolean; task: string }> = []
635
+ const { processEagerRelaunches } = await import('./index.ts')
636
+ await processEagerRelaunches(cfg, outcomes, {
637
+ env,
638
+ wakeFn: async args => {
639
+ seen.push({ personality: args.personality, runtime: args.runtime, resume: args.resume, task: args.task })
640
+ return { status: 'READY', woke: true }
641
+ },
642
+ })
643
+ expect(seen.length).toBe(1)
644
+ expect(seen[0].personality).toBe('rt')
645
+ expect(seen[0].runtime).toBeUndefined() // ← the fix: wakeOrSpawn re-resolves from the registry
646
+ expect(seen[0].resume).toBe(false)
647
+ expect(seen[0].task).toBe('')
648
+ // the mark was consumed by the relaunch
649
+ expect(hasNewEager(cfg, 'claude-rt')).toBe(false)
650
+ } finally {
651
+ rmSync(root, { recursive: true, force: true })
652
+ rmSync(laDir, { recursive: true, force: true })
653
+ }
654
+ })
655
+
624
656
  test('a DEAD session with NO .new-eager → reaped-gone, NO .idle-reaped written (crash leaves no marker)', () => {
625
657
  const { env, cfg, root, laDir } = deadSessionEnv('w')
626
658
  try {
@@ -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