@agfpd/iapeer 0.2.43 → 0.2.44

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.43",
3
+ "version": "0.2.44",
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
@@ -46,7 +46,7 @@ import {
46
46
  type WakeResult,
47
47
  } from '../lifecycle/index.ts'
48
48
  import { getAdapter } from '../launch/index.ts'
49
- import { isFoundationOwnedPlist, kickstartDaemon, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
49
+ import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
50
50
  import { resolveCallerIdentity, resolveIdentity } from '../identity/index.ts'
51
51
  import { peerProfilePath } from '../storage/index.ts'
52
52
  import {
@@ -1193,7 +1193,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1193
1193
  errOut(`rollback failed: ${rb.reason}\n`)
1194
1194
  return 1
1195
1195
  }
1196
- const restart = kickstartDaemon(env)
1196
+ const restart = cycleDaemon(env)
1197
1197
  if (restart.state === 'restarted') {
1198
1198
  const h = await waitForDaemonHealthy({ env })
1199
1199
  out(
@@ -112,7 +112,7 @@ export interface RollbackResult {
112
112
  * foundation keeps a single `.prev`, not a history stack): rollback restores the
113
113
  * binary that was live BEFORE the most recent install. Atomic (copy .prev → .tmp →
114
114
  * rename over the binary), so the binary is never absent. The CALLER restarts the
115
- * daemon afterwards (kickstartDaemon) — rollback only swaps the bytes. Sandbox-guarded.
115
+ * daemon afterwards (cycleDaemon) — rollback only swaps the bytes. Sandbox-guarded.
116
116
  */
117
117
  export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackResult {
118
118
  const binPath = iapeerBinPath(env)
@@ -61,6 +61,8 @@ export {
61
61
  isFoundationOwnedPlist,
62
62
  launchctlBootstrap,
63
63
  bootstrapJobCore,
64
+ cycleDaemon,
65
+ cycleDaemonCore,
64
66
  resolveExecutable,
65
67
  IAPEER_PLIST_OWNER_KEY,
66
68
  } from './launchd.ts'
@@ -71,6 +73,8 @@ export type {
71
73
  BootstrapState,
72
74
  BootstrapCoreDeps,
73
75
  BootstrapCoreResult,
76
+ DaemonRestartResult,
77
+ DaemonRestartState,
74
78
  LaunchctlRunner,
75
79
  } from './launchd.ts'
76
80
 
@@ -11,6 +11,7 @@ import { tmpdir } from 'os'
11
11
  import { join } from 'path'
12
12
  import {
13
13
  bootstrapJobCore,
14
+ cycleDaemonCore,
14
15
  getAdapter,
15
16
  installAlwaysOnPlist,
16
17
  isFoundationOwnedPlist,
@@ -430,3 +431,69 @@ describe('buildAlwaysOnSpec carries intelligence (telegram crash-loop guard)', (
430
431
  rmSync(cwd, { recursive: true, force: true })
431
432
  })
432
433
  })
434
+
435
+ // ─── cycleDaemonCore — the LWCR-safe deploy restart (bootout+bootstrap) ───────
436
+ // Proven live 12.06 twice: a kickstart after a binary replacement respawns into
437
+ // an EX_CONFIG(78) crash-loop when launchd's managed Launch Constraint still
438
+ // pins the OLD binary's signature (com.iapeer.arthur, then com.agfpd.iapeer
439
+ // itself on the 0.2.43 deploy). The cure — and now the ONLY restart form the
440
+ // update pipeline uses — is a full re-registration.
441
+
442
+ describe('cycleDaemonCore (LWCR-safe bootout+bootstrap)', () => {
443
+ function harness(script: { printStatuses: number[]; bootoutStatus?: number; bootstrapStatuses?: number[] }) {
444
+ const calls: string[][] = []
445
+ let printI = 0
446
+ let bootI = 0
447
+ const run = (args: string[]) => {
448
+ calls.push(args)
449
+ if (args[0] === 'print') {
450
+ const status = script.printStatuses[Math.min(printI, script.printStatuses.length - 1)]!
451
+ printI++
452
+ return { status, stderr: '' }
453
+ }
454
+ if (args[0] === 'bootout') return { status: script.bootoutStatus ?? 0, stderr: script.bootoutStatus ? 'boom' : '' }
455
+ const status = (script.bootstrapStatuses ?? [0])[Math.min(bootI, (script.bootstrapStatuses ?? [0]).length - 1)]!
456
+ bootI++
457
+ return { status, stderr: status === 0 ? '' : 'Bootstrap failed' }
458
+ }
459
+ return { calls, deps: { run, sleepMs: () => {} } }
460
+ }
461
+
462
+ test('loaded job: bootout → gone → bootstrap → restarted', () => {
463
+ // print: loaded (pre-check) → gone (core wait) → bootstrap ok
464
+ const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [0] })
465
+ expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'restarted' })
466
+ expect(h.calls.some(c => c[0] === 'bootout')).toBe(true)
467
+ expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(true)
468
+ })
469
+
470
+ test('not loaded → not-loaded, NOTHING booted out', () => {
471
+ const h = harness({ printStatuses: [1] })
472
+ expect(cycleDaemonCore('501', '/p.plist', h.deps)).toEqual({ state: 'not-loaded' })
473
+ expect(h.calls.some(c => c[0] === 'bootout')).toBe(false)
474
+ })
475
+
476
+ test('bootout fails → failed loud, no bootstrap attempt', () => {
477
+ const h = harness({ printStatuses: [0], bootoutStatus: 9 })
478
+ const r = cycleDaemonCore('501', '/p.plist', h.deps)
479
+ expect(r.state).toBe('failed')
480
+ expect(r.detail).toContain('bootout failed')
481
+ expect(h.calls.some(c => c[0] === 'bootstrap')).toBe(false)
482
+ })
483
+
484
+ test('job never unloads after bootout (undead beyond budget) → failed with the manual recipe', () => {
485
+ // print: loaded (pre-check), then loaded FOREVER (the core wait + retries see it listed)
486
+ const h = harness({ printStatuses: [0, 0] })
487
+ const r = cycleDaemonCore('501', '/p.plist', h.deps)
488
+ expect(r.state).toBe('failed')
489
+ expect(r.detail).toContain('manual rescue')
490
+ })
491
+
492
+ test('bootstrap retries exhausted → failed with rescue hint', () => {
493
+ // pre-check loaded → gone for the wait → every bootstrap attempt fails, re-checks stay gone
494
+ const h = harness({ printStatuses: [0, 1], bootstrapStatuses: [5, 5, 5, 5] })
495
+ const r = cycleDaemonCore('501', '/p.plist', h.deps)
496
+ expect(r.state).toBe('failed')
497
+ expect(r.detail).toContain('manual rescue')
498
+ })
499
+ })
@@ -299,34 +299,87 @@ export function bootstrapJobCore(
299
299
  }
300
300
 
301
301
  export type DaemonRestartState =
302
- | 'restarted' // kickstart -k succeeded → the daemon is now on the freshly-installed binary
302
+ | 'restarted' // bootout + bootstrap succeeded → the daemon runs the freshly-installed binary
303
303
  | 'not-loaded' // com.agfpd.iapeer is not in the gui domain → nothing to restart (new binary
304
304
  // will be used whenever it IS next started; nothing to do here)
305
305
  | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch the real launchd
306
- | 'failed' // launchctl kickstart exited non-zero
306
+ | 'failed' // bootout/bootstrap failed — the result carries the manual rescue recipe
307
307
 
308
308
  export interface DaemonRestartResult {
309
309
  state: DaemonRestartState
310
310
  detail?: string
311
311
  }
312
312
 
313
+ /**
314
+ * DI core of cycleDaemon (unit-testable without launchctl): bootout → wait-for-gone
315
+ * → bootstrap, composed on bootstrapJobCore's undead-job-safe wait/backoff.
316
+ * Disambiguation of bootstrapJobCore's 'already-loaded': at attempts=0 it means the
317
+ * job NEVER unloaded after our bootout (undead beyond budget) → FAIL loud with the
318
+ * manual recipe; at attempts>0 it means a retry found the job loaded (our previous
319
+ * attempt half-succeeded or KeepAlive raced us in) → the job IS up → success.
320
+ */
321
+ export function cycleDaemonCore(
322
+ uid: string,
323
+ plistPath: string,
324
+ deps: BootstrapCoreDeps,
325
+ ): DaemonRestartResult {
326
+ const listed = () => deps.run(['print', `gui/${uid}/${DAEMON_PLIST_LABEL}`]).status === 0
327
+ if (!listed()) return { state: 'not-loaded' }
328
+ const bo = deps.run(['bootout', `gui/${uid}/${DAEMON_PLIST_LABEL}`])
329
+ if (bo.status !== 0) {
330
+ return { state: 'failed', detail: `launchctl bootout failed: ${bo.stderr.trim() || `exit ${bo.status}`}` }
331
+ }
332
+ const core = bootstrapJobCore(uid, DAEMON_PLIST_LABEL, plistPath, deps)
333
+ if (core.state === 'loaded' || (core.state === 'already-loaded' && core.attempts > 0)) {
334
+ return { state: 'restarted' }
335
+ }
336
+ if (core.state === 'already-loaded') {
337
+ return {
338
+ state: 'failed',
339
+ detail:
340
+ `job never unloaded after bootout (undead beyond budget) — manual rescue: ` +
341
+ `launchctl bootout gui/${uid}/${DAEMON_PLIST_LABEL} && launchctl bootstrap gui/${uid} ${plistPath}`,
342
+ }
343
+ }
344
+ return {
345
+ state: 'failed',
346
+ detail: `${core.detail ?? 'bootstrap failed'} — manual rescue: launchctl bootstrap gui/${uid} ${plistPath}`,
347
+ }
348
+ }
349
+
313
350
  /**
314
351
  * Restart the foundation daemon (`com.agfpd.iapeer`) onto the binary currently on
315
- * disk — `launchctl kickstart -k gui/<uid>/com.agfpd.iapeer`. This is the "activate
316
- * the freshly-installed binary" step of `iapeer update`: a compiled daemon holds its
317
- * old code in memory until restarted, and `-k` SIGKILLs + relaunches it (KeepAlive
318
- * brings it right back). Only restarts when the service is actually loaded — a
319
- * not-loaded daemon needs no restart (the new binary is taken on its next start).
320
- * Never touches the plist (the launch path is version-stable). Sandbox-safe.
352
+ * disk — `launchctl bootout` + `bootstrap`, DELIBERATELY NOT `kickstart -k`.
353
+ *
354
+ * LWCR (launch constraints, the deploy bomb — PROVEN LIVE 12.06 twice): after a
355
+ * binary replacement, launchd's managed Launch Constraint for the REGISTERED job
356
+ * can still pin the OLD binary's signature — a kickstart then respawns into an
357
+ * EX_CONFIG(78) crash-loop (`state = spawn scheduled`, exit 78 from launchd
358
+ * itself; the binary runs fine by hand). Reproduced on com.iapeer.arthur
359
+ * (telegram-runtime's v0.14.0 deploy, bridge down 6 min) AND on com.agfpd.iapeer
360
+ * itself (the 0.2.43 deploy — daemon down until the manual cure). The ONLY cure
361
+ * is a full re-registration: bootout drops the job (and its constraint),
362
+ * bootstrap registers it against the binary now on disk. kickstart is
363
+ * structurally insufficient for this class.
364
+ *
365
+ * Composed on bootstrapJobCore (undead-job-safe wait-for-gone + backoff). Only
366
+ * cycles when the service is actually loaded — a not-loaded daemon needs no
367
+ * restart (the new binary is taken on its next start). The plist itself is
368
+ * version-stable (write-if-changed) and is NOT rewritten here. Sandbox-safe.
321
369
  */
322
- export function kickstartDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
323
- if (env.IAPEER_TEST_SANDBOX === '1') return { state: 'skipped-sandbox' }
370
+ export function cycleDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestartResult {
371
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
372
+ return { state: 'skipped-sandbox' }
373
+ }
324
374
  const uid = currentUid()
325
- if (!isLaunchdLoaded(DAEMON_PLIST_LABEL, uid)) return { state: 'not-loaded' }
326
- const r = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${DAEMON_PLIST_LABEL}`], { encoding: 'utf8' })
327
- return r.status === 0
328
- ? { state: 'restarted' }
329
- : { state: 'failed', detail: (r.stderr ?? '').trim() || `launchctl kickstart exit ${r.status}` }
375
+ const plistPath = join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
376
+ return cycleDaemonCore(uid, plistPath, {
377
+ run: args => {
378
+ const r = spawnSync('launchctl', args, { encoding: 'utf8' })
379
+ return { status: r.status, stderr: r.stderr ?? '' }
380
+ },
381
+ sleepMs: ms => spawnSync('sleep', [String(ms / 1000)]),
382
+ })
330
383
  }
331
384
 
332
385
  /**
@@ -10,7 +10,7 @@
10
10
  // COMPILED binary can't rebuild itself, so we pull the freshly-published package
11
11
  // and run ITS own source installer. DELIBERATELY NOT `npx … install` (see
12
12
  // defaultRunInstall for why npx is unsafe here).
13
- // 4. kickstart com.agfpd.iapeer IF loaded (activate the new binary)
13
+ // 4. cycle com.agfpd.iapeer IF loaded (bootout+bootstrap) (activate the new binary; kickstart hits the LWCR EX_CONFIG(78) bomb)
14
14
  //
15
15
  // Scope: the foundation ONLY (the @agfpd/iapeer binary + its daemon). It never
16
16
  // touches the plist (version-stable launch path) and never touches other packages
@@ -27,7 +27,7 @@ import { tmpdir } from 'os'
27
27
  import { join } from 'path'
28
28
  import { IapError } from '../core/errors.ts'
29
29
  import { IAPEER_VERSION } from '../core/version.ts'
30
- import { kickstartDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
30
+ import { cycleDaemon, type DaemonRestartResult } from '../launch/launchd.ts'
31
31
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
32
32
 
33
33
  /** The npm package the foundation publishes / updates from. */
@@ -48,7 +48,7 @@ export interface UpdateDeps {
48
48
  /** Pull + rebuild the binary for `version` (default: `npx @agfpd/iapeer@<v> install`).
49
49
  * Returns true on success. */
50
50
  runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
51
- /** Restart the daemon onto the new binary (default: kickstartDaemon). */
51
+ /** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
52
52
  restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
53
53
  }
54
54
 
@@ -68,7 +68,7 @@ export interface UpdateResult {
68
68
 
69
69
  // ─────────────────────────────────────────────────────────────────────────────
70
70
  // Post-restart health — verify the daemon actually came up, not just that
71
- // `launchctl kickstart` exited 0. A new binary that fails to boot would otherwise
71
+ // the daemon job cycle exited 0. A new binary that fails to boot would otherwise
72
72
  // be reported as a successful "restarted". The probe connects to the daemon's unix
73
73
  // socket (the always-present same-uid listener it binds right before printing READY);
74
74
  // a refused connection = not serving. Requires a short STREAK of successes so a
@@ -240,7 +240,7 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
240
240
  }
241
241
  }
242
242
 
243
- const restart = deps.restartDaemon ?? kickstartDaemon
243
+ const restart = deps.restartDaemon ?? cycleDaemon
244
244
  const d = restart(env)
245
245
  return {
246
246
  status: 'updated',