@agfpd/iapeer 0.4.54 → 0.4.56

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.4.54",
3
+ "version": "0.4.56",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -1261,7 +1261,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
1261
1261
  sig: 'default-runtime <runtime> (--peer <p> | --all)',
1262
1262
  desc: 'flip the PRIMARY (routing/wake default) — the fleet-switch lever; refuses an undeclared runtime; registry self-healed in the same command',
1263
1263
  },
1264
- { sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then tmux attach' },
1264
+ { sig: 'attach <peer> [runtime]', desc: 'ensure-live + resume, then attach to the pty session (Ctrl-] detaches)' },
1265
1265
  { sig: 'interrupt <peer> [runtime]', desc: 'interrupt the current turn (Escape) — context intact' },
1266
1266
  { sig: 'compact <peer> [runtime]', desc: 'compact the peer\'s dialogue (/compact); resumes clean-asleep first' },
1267
1267
  { sig: 'self-fresh', desc: '(agent self-call) mark /new eager-fresh + self-kill — the daemon relaunches fresh' },
@@ -2115,7 +2115,12 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2115
2115
  }
2116
2116
 
2117
2117
  // ── Foundation (the core; a hard failure aborts the cascade) ──
2118
- const r = updateIapeer({ env, force: flags.force === true, targetVersion: pinned })
2118
+ // updateIapeer owns the whole settle sequence now: restart → health-gate →
2119
+ // (infra recycle + known-good stamp) — В54: infra recycles ONLY after health;
2120
+ // В50: the healthy binary is stamped so the next install may refresh `.prev`;
2121
+ // В53: "already-latest" also verifies the LIVE daemon runs the binary's version
2122
+ // (an interrupted prior update heals with a restart instead of a false no-op).
2123
+ const r = await updateIapeer({ env, force: flags.force === true, targetVersion: pinned })
2119
2124
  let foundationHardFail = false
2120
2125
  if (r.status === 'failed') {
2121
2126
  errOut(`update failed: ${r.reason}\n`)
@@ -2123,15 +2128,13 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2123
2128
  } else if (r.status === 'already-latest') {
2124
2129
  out(`foundation: already at version ${r.from}\n`)
2125
2130
  } else if (r.daemon === 'restarted') {
2126
- // 'updated' + daemon restarted: VERIFY it actually came back up (a binary that
2127
- // fails to boot must not read as a healthy deploy — it's the cue to roll back).
2128
- const h = await waitForDaemonHealthy({ env })
2129
- if (!h.healthy) {
2130
- errOut(`foundation: updated ${r.from} → ${r.to} but the daemon is NOT healthy after restart (${h.detail}).\nroll back now: iapeer rollback\n`)
2131
+ if (r.healthy !== true) {
2132
+ errOut(`foundation: updated ${r.from} → ${r.to} but ${r.reason ?? 'the daemon is NOT healthy after restart'}.\nroll back now: iapeer rollback\n`)
2131
2133
  foundationHardFail = true
2132
2134
  } else {
2133
2135
  const note = infraRecycleNote(r.infra)
2134
- ;(infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; daemon restarted and healthy${note}\n`)
2136
+ const healNote = r.healedStaleDaemon ? ` (live daemon was still on ${r.healedStaleDaemon} — healed)` : ''
2137
+ ;(infraRecycleFailed(r.infra) ? errOut : out)(`foundation: updated ${r.from} → ${r.to}; daemon restarted and healthy${healNote}${note}\n`)
2135
2138
  }
2136
2139
  } else {
2137
2140
  const daemonNote =
@@ -2166,7 +2169,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2166
2169
  // daemon onto it, and verify health. ONE level deep (single .prev). Cloud is
2167
2170
  // still the source of truth — rollback is the local "undo the last update" while
2168
2171
  // a fixed version is published.
2169
- const { rollbackIapeer } = await import('../install/index.ts')
2172
+ const { rollbackIapeer, stampBinaryHealthy } = await import('../install/index.ts')
2170
2173
  const rb = rollbackIapeer(env)
2171
2174
  if (rb.status === 'failed') {
2172
2175
  errOut(`rollback failed: ${rb.reason}\n`)
@@ -2178,6 +2181,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2178
2181
  const infraFailed = infraRecycleFailed(infra)
2179
2182
  if (restart.state === 'restarted') {
2180
2183
  const h = await waitForDaemonHealthy({ env })
2184
+ // В50 — a restored binary that just proved healthy is known-good: stamp it so
2185
+ // the NEXT install may refresh `.prev` from it (sandbox never reaches here).
2186
+ if (h.healthy) stampBinaryHealthy(env)
2181
2187
  const msg = h.healthy
2182
2188
  ? `rolled back to the previous binary; daemon restarted and healthy${infraNote}\n`
2183
2189
  : `rolled back, but the daemon is NOT healthy after restart (${h.detail})${infraNote}\n`
@@ -2234,7 +2240,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2234
2240
  return 1
2235
2241
  }
2236
2242
  }
2237
- const res = executeUninstall({ env, removeCodesignIdentity: removeCodesign })
2243
+ const res = await executeUninstall({ env, removeCodesignIdentity: removeCodesign })
2238
2244
  for (const r of res.removed) out(` removed: ${r}\n`)
2239
2245
  for (const s of res.skipped) out(` skipped (absent): ${s}\n`)
2240
2246
  for (const f of res.failed) errOut(` FAILED: ${f.what} — ${f.detail}\n`)
@@ -2276,6 +2282,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2276
2282
  out(
2277
2283
  `installed iapeer → ${r.binPath}` +
2278
2284
  `${r.prevPath ? ` (previous kept: ${r.prevPath})` : ''}` +
2285
+ `${r.prevKept ? ` (.prev NOT overwritten: ${r.prevKept.reason})` : ''}` +
2279
2286
  `${r.size ? ` (${Math.round(r.size / 1e6)}M)` : ''}\n` +
2280
2287
  signingLine +
2281
2288
  ` scaffold: ~/.iapeer/ ensured (peers/, state, logs, cache, runtimes)\n` +
@@ -41,6 +41,7 @@ import {
41
41
  } from '../core/constants.ts'
42
42
  import { parseSessionName } from '../core/socket.ts'
43
43
  import { IapError } from '../core/errors.ts'
44
+ import { IAPEER_VERSION } from '../core/version.ts'
44
45
  import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
45
46
  import { readPeersIndex, type PeersIndex } from '../registry/index.ts'
46
47
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
@@ -48,7 +49,10 @@ import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type S
48
49
  import { appendDeliveryEvent } from './deliverylog.ts'
49
50
 
50
51
  export const CALLER_HEADER = 'x-iapeer-identity'
51
- const SERVER_INFO = { name: 'iapeer', version: '0.0.0' }
52
+ // В53 — the LIVE daemon's version must be observable (the update verb's
53
+ // "already-latest" gate compares it against the installed binary to detect an
54
+ // interrupted update: binary swapped, daemon never restarted). Baked at build time.
55
+ const SERVER_INFO = { name: 'iapeer', version: IAPEER_VERSION }
52
56
 
53
57
  /** Does a LIVE listener answer on this unix socket path? (В28) A successful connect ⇒ a daemon is
54
58
  * running; ECONNREFUSED ⇒ a stale socket file (safe to unlink). Bounded by a short timeout so a wedged
@@ -561,7 +565,10 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
561
565
  let discoveryPath: string | undefined
562
566
  if (opts.discovery) {
563
567
  discoveryPath = daemonDiscoveryPath(opts)
564
- writeFileAtomic(discoveryPath, `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null })}\n`)
568
+ // `version` (В53): the RUNNING daemon's baked version — lets `iapeer update`
569
+ // detect an interrupted update (binary already at latest, live daemon still on
570
+ // the old code) and heal it with a restart instead of a false "already-latest".
571
+ writeFileAtomic(discoveryPath, `${JSON.stringify({ sock: socketPath ?? null, tcp: url ?? null, version: IAPEER_VERSION })}\n`)
565
572
  }
566
573
 
567
574
  const removeArtifacts = (): void => {
@@ -13,7 +13,8 @@
13
13
  // signInstalledBinary (signing.ts) re-signs each install with the
14
14
  // stable local identity so the designated requirement — and the grants — survive.
15
15
 
16
- import { copyFileSync, cpSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
16
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'fs'
17
+ import { createHash } from 'crypto'
17
18
  import { homedir } from 'os'
18
19
  import { basename, join, relative, sep } from 'path'
19
20
  import { spawnSync } from 'child_process'
@@ -35,6 +36,10 @@ export interface InstallResult {
35
36
  binPath: string
36
37
  /** The previous binary preserved for rollback (when one existed). */
37
38
  prevPath?: string
39
+ /** В50 — set when the outgoing binary was NOT copied to `.prev` because the healthy-stamp
40
+ * says it never passed a post-deploy health-check: the existing `.prev` (last KNOWN-GOOD)
41
+ * is preserved so a retry after a broken release cannot destroy the rollback target. */
42
+ prevKept?: { reason: string }
38
43
  /** Bytes of the installed binary. */
39
44
  size?: number
40
45
  /** Stable-identity re-sign outcome (TCC grants survive updates). Soft: a signing
@@ -42,6 +47,69 @@ export interface InstallResult {
42
47
  signing?: SigningOutcome
43
48
  }
44
49
 
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // В50 — known-good `.prev`. Without a gate, EVERY install copies the current
52
+ // binary to `.prev`: a series of broken releases overwrites the known-good
53
+ // rollback target with a broken binary (retry of an update destroys the recovery
54
+ // path). The gate: `iapeer update`/`rollback` stamp `<bin>.healthy` with the
55
+ // sha256 of the binary ONLY after the daemon passed its post-deploy health-check.
56
+ // The next install copies the current binary to `.prev` only when the stamp
57
+ // matches it (proven-healthy). No stamp at all → legacy behavior (copy) — first
58
+ // update after this ships, manual `bin/iapeer install` runs, and fresh bootstraps
59
+ // keep working unchanged. NB the stamp is CROSS-VERSION state: install runs from
60
+ // the NEWLY-FETCHED package's source while the stamp was written by the OUTGOING
61
+ // version — keep the format trivial (hex digest + newline).
62
+ // ─────────────────────────────────────────────────────────────────────────────
63
+
64
+ /** The healthy-stamp path kept next to the binary. */
65
+ export function iapeerHealthyStampPath(env: NodeJS.ProcessEnv = process.env): string {
66
+ return `${iapeerBinPath(env)}.healthy`
67
+ }
68
+
69
+ function sha256File(path: string): string {
70
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
71
+ }
72
+
73
+ /** Record the CURRENT installed binary as known-good (call ONLY after a passed
74
+ * post-deploy health-check). Best-effort by design — a stamp hiccup must never
75
+ * fail a successful update; it only means the next install falls back to
76
+ * preserving the older `.prev`. */
77
+ export function stampBinaryHealthy(env: NodeJS.ProcessEnv = process.env): boolean {
78
+ const binPath = iapeerBinPath(env)
79
+ assertInstallSandboxIsolated(binPath, env)
80
+ try {
81
+ writeFileSync(iapeerHealthyStampPath(env), `${sha256File(binPath)}\n`, { mode: 0o644 })
82
+ return true
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ /** The В50 decision: should the CURRENT binary become `.prev`? Copy when the
89
+ * healthy-stamp matches it (proven-good) or when no stamp exists (legacy /
90
+ * first stamped update / manual install); preserve the existing `.prev` when
91
+ * the stamp EXISTS but does not match (the current binary never passed a
92
+ * health-check — a broken release being retried). Pure read — exported for
93
+ * hermetic tests (installIapeer itself runs a full compile). */
94
+ export function shouldCopyCurrentToPrev(binPath: string): { copy: boolean; reason?: string } {
95
+ let stamped: string | undefined
96
+ try {
97
+ const stampPath = `${binPath}.healthy`
98
+ stamped = existsSync(stampPath) ? readFileSync(stampPath, 'utf8').trim() : undefined
99
+ } catch {
100
+ stamped = undefined // unreadable stamp → treat as absent (legacy copy)
101
+ }
102
+ if (stamped && stamped !== sha256File(binPath)) {
103
+ return {
104
+ copy: false,
105
+ reason:
106
+ 'current binary never passed a post-deploy health-check (healthy-stamp mismatch) — ' +
107
+ 'keeping the existing .prev as the known-good rollback target',
108
+ }
109
+ }
110
+ return { copy: true }
111
+ }
112
+
45
113
  /**
46
114
  * Build the standalone `iapeer` binary from the CLI entrypoint and place it at the
47
115
  * stable path ATOMICALLY: `bun build --compile <entry> → <bin>.tmp`, then rename over
@@ -76,14 +144,30 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
76
144
  throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
77
145
  }
78
146
  let prevPath: string | undefined
147
+ let prevKept: InstallResult['prevKept']
79
148
  if (existsSync(binPath)) {
80
- prevPath = `${binPath}.prev`
81
- // COPY (not move) the current binary to .prev so binPath is NEVER absent (audit
82
- // #7): a move-then-move leaves no binary in the window between the two renames —
83
- // if the second throws, the prod daemon + infra fleet crash-loop with no bin.
84
- copyFileSync(binPath, prevPath)
149
+ // В50 — copy to .prev ONLY when shouldCopyCurrentToPrev says the current binary
150
+ // is trustworthy; otherwise preserve the existing .prev (last known-good).
151
+ const verdict = shouldCopyCurrentToPrev(binPath)
152
+ if (!verdict.copy) {
153
+ prevKept = { reason: verdict.reason! }
154
+ } else {
155
+ prevPath = `${binPath}.prev`
156
+ // COPY (not move) the current binary to .prev so binPath is NEVER absent (audit
157
+ // #7): a move-then-move leaves no binary in the window between the two renames —
158
+ // if the second throws, the prod daemon + infra fleet crash-loop with no bin.
159
+ copyFileSync(binPath, prevPath)
160
+ }
85
161
  }
86
162
  renameSync(tmp, binPath) // atomic replace in place (POSIX rename over an existing file)
163
+ // The stamp described the binary we just REPLACED — it must not vouch for the new
164
+ // bytes. Health of the new binary is proven (and re-stamped) by the update verb
165
+ // AFTER its health-check passes.
166
+ try {
167
+ unlinkSync(`${binPath}.healthy`)
168
+ } catch {
169
+ /* absent — fine */
170
+ }
87
171
  // Stable-identity re-sign (TCC grants survive updates). AFTER the rename: the
88
172
  // signature belongs to the final inode at the final path. Soft-fail by design.
89
173
  const signing = signInstalledBinary(binPath, env)
@@ -93,7 +177,7 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
93
177
  } catch {
94
178
  /* best-effort */
95
179
  }
96
- return { binPath, prevPath, size, signing }
180
+ return { binPath, prevPath, prevKept, size, signing }
97
181
  }
98
182
 
99
183
  /**
@@ -13,7 +13,7 @@
13
13
  // here so the crashloop bound is visible and tunable, not an implicit default; raise
14
14
  // throttleIntervalSecs for a wider window). RunAtLoad+KeepAlive = always-on.
15
15
 
16
- import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
16
+ import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs'
17
17
  import { homedir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { spawnSync } from 'child_process'
@@ -631,6 +631,24 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
631
631
  if (!(sandbox && logDir.startsWith(`${realRoot}/`))) {
632
632
  mkdirSync(logDir, { recursive: true, mode: 0o700 })
633
633
  }
634
- writeFileSync(path, renderLaunchdPlist(spec), { mode: 0o644 })
634
+ // В46 — fail-closed sandbox guard on the PLIST WRITE itself (symmetric to the mkdir
635
+ // guard above and the registry/install guards): a sandboxed test that forgot
636
+ // IAPEER_LAUNCHAGENTS_DIR must NEVER write a real ~/Library/LaunchAgents/
637
+ // com.iapeer.<p>.plist — the live daemon reads that file as the H4 launchd-owned
638
+ // marker, so a leaked test plist silently stops the daemon from waking the
639
+ // matching peer (plist present but never loaded → peer reads as dead forever).
640
+ const realAgents = join(homedir(), 'Library', 'LaunchAgents')
641
+ if (sandbox && path.startsWith(`${realAgents}/`)) {
642
+ throw new IapError(
643
+ `refusing to write a REAL LaunchAgents plist (${path}) under IAPEER_TEST_SANDBOX=1 — ` +
644
+ 'set IAPEER_LAUNCHAGENTS_DIR to an isolated path',
645
+ )
646
+ }
647
+ // В46 — atomic write (tmp + rename): an interrupted direct write leaves a TRUNCATED
648
+ // plist without the ownership sentinel, which the collision guard above then reads
649
+ // as foreign — the peer becomes permanently un-provisionable ("refused-foreign").
650
+ const tmp = `${path}.tmp-${process.pid}`
651
+ writeFileSync(tmp, renderLaunchdPlist(spec), { mode: 0o644 })
652
+ renameSync(tmp, path)
635
653
  return path
636
654
  }
@@ -21,7 +21,7 @@ import { homedir } from 'os'
21
21
  import { type Runtime } from '../core/constants.ts'
22
22
  import { IapError } from '../core/errors.ts'
23
23
  import { createPeer, type CreatePeerResult } from '../create/index.ts'
24
- import { readRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
24
+ import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
25
25
 
26
26
  // ─────────────────────────────────────────────────────────────────────────────
27
27
  // Built-in runtime → npm package registry (§6): onboard AUTO-resolves
@@ -131,7 +131,8 @@ export interface InstallRuntimePackageResult {
131
131
  }
132
132
 
133
133
  /** Injectable npx runner (so tests / a sandbox proof can simulate the package's
134
- * self-deploy without a published npm package). Default: `npx -y <package>`. */
134
+ * self-deploy without a published npm package). Default: `npx -y <spec>` — the spec
135
+ * may carry a version pin (`pkg@1.2.3`, В51). */
135
136
  export type NpxRunner = (pkg: string, env: NodeJS.ProcessEnv) => { ok: boolean; detail?: string }
136
137
 
137
138
  const defaultNpxRunner: NpxRunner = (pkg, env) => {
@@ -153,6 +154,10 @@ export interface InstallRuntimePackageOptions {
153
154
  runtime: Runtime
154
155
  /** Override the built-in package mapping (--package). */
155
156
  package?: string
157
+ /** В51 — pin the delivered version (`npx -y <pkg>@<version>`): without a pin an
158
+ * unpinned npx can serve a STALE cached/propagating version while the caller
159
+ * reports the npm-latest it resolved. Omit → unpinned (bootstrap convenience). */
160
+ version?: string
156
161
  /** Re-run npx even when a manifest is already present (force a package update). */
157
162
  force?: boolean
158
163
  env?: NodeJS.ProcessEnv
@@ -164,21 +169,39 @@ export interface InstallRuntimePackageOptions {
164
169
  * Ensure a runtime PACKAGE is installed (the package self-deploys its bin + manifest
165
170
  * via `npx`). IDEMPOTENT: when a manifest is already present (package installed) and
166
171
  * not forced, it is a no-op (`skipped`). Otherwise resolves the package (override →
167
- * built-in registry) and runs npx. No mapping + no --package + no manifest → `no-package`.
172
+ * the manifest's `package` stamp → built-in registry) and runs npx. No mapping + no
173
+ * --package + no manifest → `no-package`.
174
+ *
175
+ * В52 — after a successful run, the resolved package name is stamped into the
176
+ * manifest the package just wrote (when it differs from what the manifest carries):
177
+ * that is what lets a custom-packaged runtime survive the FU12 cascade and direct
178
+ * `update-runtime` without the operator repeating --package every time.
168
179
  */
169
180
  export function installRuntimePackage(opts: InstallRuntimePackageOptions): InstallRuntimePackageResult {
170
181
  const env = opts.env ?? process.env
171
- const pkg = resolveRuntimePackage(opts.runtime, opts.package)
172
- const manifestPresent = readRuntimeManifest(opts.runtime, { env }) !== null
173
- if (manifestPresent && !opts.force) {
182
+ const existing = readRuntimeManifest(opts.runtime, { env })
183
+ const pkg = resolveRuntimePackage(opts.runtime, opts.package ?? existing?.package)
184
+ if (existing && !opts.force) {
174
185
  return { runtime: opts.runtime, package: pkg, state: 'skipped' }
175
186
  }
176
187
  if (!pkg) {
177
188
  return { runtime: opts.runtime, state: 'no-package' }
178
189
  }
179
190
  const run = opts.runNpx ?? defaultNpxRunner
180
- const r = run(pkg, env)
181
- return { runtime: opts.runtime, package: pkg, state: r.ok ? 'ran' : 'failed', detail: r.detail }
191
+ const spec = opts.version ? `${pkg}@${opts.version}` : pkg
192
+ const r = run(spec, env)
193
+ if (!r.ok) {
194
+ return { runtime: opts.runtime, package: pkg, state: 'failed', detail: r.detail }
195
+ }
196
+ // В52 — stamp the delivering package into the fresh manifest (best-effort: a
197
+ // missing manifest is the package's own contract breach, surfaced elsewhere).
198
+ try {
199
+ const fresh = readRuntimeManifest(opts.runtime, { env })
200
+ if (fresh && fresh.package !== pkg) writeRuntimeManifest({ ...fresh, package: pkg }, { env })
201
+ } catch {
202
+ /* malformed manifest → the read path reports it loudly on the next use */
203
+ }
204
+ return { runtime: opts.runtime, package: pkg, state: 'ran' }
182
205
  }
183
206
 
184
207
  export interface OnboardRuntimeOptions extends DeployRuntimeOptions {
@@ -55,6 +55,12 @@ export interface RuntimeManifest {
55
55
  * `update-runtime` version-gates on it; absent → no gate, the update
56
56
  * re-installs idempotently. */
57
57
  version?: string
58
+ /** OPTIONAL npm package that delivered this runtime (В52). Stamped by the
59
+ * FOUNDATION after a successful install with an explicit --package override, so
60
+ * a custom-packaged runtime survives the FU12 cascade and a direct
61
+ * `update-runtime` (resolution order: explicit override → this stamp → the
62
+ * built-in registry). A package may also self-declare it. */
63
+ package?: string
58
64
  /** OPTIONAL per-peer self-config hook (the shared contract both modes call). */
59
65
  selfConfig?: SelfConfigDescriptor
60
66
  /** OPTIONAL fixed peer-set (mode a). Omitted by an operator-add runtime (mode b). */
@@ -113,7 +119,14 @@ function normalizeManifest(raw: unknown, runtime: Runtime): RuntimeManifest {
113
119
  })
114
120
  }
115
121
  const version = typeof obj.version === 'string' && obj.version.trim() ? obj.version.trim() : undefined
116
- return { runtime: declaredRuntime, ...(version ? { version } : {}), ...(selfConfig ? { selfConfig } : {}), ...(peers ? { peers } : {}) }
122
+ const pkg = typeof obj.package === 'string' && obj.package.trim() ? obj.package.trim() : undefined
123
+ return {
124
+ runtime: declaredRuntime,
125
+ ...(version ? { version } : {}),
126
+ ...(pkg ? { package: pkg } : {}),
127
+ ...(selfConfig ? { selfConfig } : {}),
128
+ ...(peers ? { peers } : {}),
129
+ }
117
130
  }
118
131
 
119
132
  /** Read a runtime's manifest (~/.iapeer/runtimes/<runtime>/runtime.json), or null when
@@ -18,8 +18,11 @@
18
18
  // the stamp cannot be gated — the update proceeds idempotently and says so.
19
19
 
20
20
  import { spawnSync } from 'child_process'
21
- import { type Runtime } from '../core/constants.ts'
21
+ import { readdirSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { isRuntime, type Runtime } from '../core/constants.ts'
22
24
  import { readPeersIndex } from '../registry/index.ts'
25
+ import { resolveGlobalRoot } from '../storage/index.ts'
23
26
  import { readRuntimeManifest } from './index.ts'
24
27
  import {
25
28
  deployRuntime,
@@ -106,7 +109,9 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
106
109
  detail: `no manifest for "${runtime}" — install first: iapeer install-runtime ${runtime}`,
107
110
  }
108
111
  }
109
- const pkg = resolveRuntimePackage(runtime)
112
+ // В52 — the manifest's `package` stamp (a custom --package install) wins over the
113
+ // built-in registry, so a custom-packaged runtime updates from ITS package.
114
+ const pkg = resolveRuntimePackage(runtime, manifest.package)
110
115
  if (!pkg) {
111
116
  return { runtime, state: 'not-installed', peers: [], restarted: [], detail: `no package mapping for "${runtime}"` }
112
117
  }
@@ -122,12 +127,31 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
122
127
  return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
123
128
  }
124
129
 
125
- // Re-install (forced npx — the package self-deploys its new bin + manifest).
126
- const install = installRuntimePackage({ runtime, force: true, env, runNpx: opts.runNpx })
130
+ // Re-install, PINNED to the version the gate resolved (В51 — an unpinned npx can
131
+ // serve a stale cached/propagating build while we report "updated → latest").
132
+ const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx })
127
133
  if (install.state !== 'ran') {
128
134
  return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
129
135
  }
130
136
 
137
+ // В51 — verify the DELIVERED version: re-read the manifest the package just wrote
138
+ // and compare its stamp against the gate's target. A mismatch = the npx cache/CDN
139
+ // served old code — fail LOUD, never report 'updated' for a version that did not
140
+ // arrive. (No stamp in the new manifest → unverifiable; reported, not fatal.)
141
+ const delivered = readRuntimeManifest(runtime, { env })
142
+ if (delivered?.version && delivered.version !== latest) {
143
+ return {
144
+ runtime,
145
+ package: pkg,
146
+ state: 'install-failed',
147
+ from: installed,
148
+ to: latest,
149
+ peers: [],
150
+ restarted: [],
151
+ detail: `delivered version ${delivered.version} ≠ target ${latest} (stale npx cache / CDN propagation) — retry in ~1 min`,
152
+ }
153
+ }
154
+
131
155
  // IDEMPOTENT re-provision via the SAME deploy path install-runtime uses: blurb
132
156
  // sync, re-self-config, no-clobber on live peers. Mode-b (telegram) declares no
133
157
  // peers — the deploy is an empty pass, by design.
@@ -157,6 +181,10 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
157
181
  )
158
182
  }
159
183
 
184
+ const notes = [
185
+ installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
186
+ delivered?.version ? undefined : 'no version stamp in the NEW manifest — delivered version unverified',
187
+ ].filter(Boolean)
160
188
  return {
161
189
  runtime,
162
190
  package: pkg,
@@ -165,16 +193,32 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
165
193
  to: latest,
166
194
  peers,
167
195
  restarted,
168
- detail: installed ? undefined : 'no version stamp in the old manifest — gate skipped, re-installed idempotently',
196
+ detail: notes.length ? notes.join('; ') : undefined,
197
+ }
198
+ }
199
+
200
+ /** Every runtime with a manifest on THIS host (~/.iapeer/runtimes/<r>/runtime.json) —
201
+ * the union the FU12 cascade must cover. В52: scanning the disk (not the frozen
202
+ * built-in registry) is what keeps a custom `--package` runtime in the cascade. */
203
+ function installedRuntimes(env: NodeJS.ProcessEnv): Runtime[] {
204
+ let entries: string[] = []
205
+ try {
206
+ entries = readdirSync(join(resolveGlobalRoot(env), 'runtimes'))
207
+ } catch {
208
+ /* no runtimes dir → nothing installed */
169
209
  }
210
+ return entries.filter(e => isRuntime(e) && readRuntimeManifest(e as Runtime, { env }) !== null) as Runtime[]
170
211
  }
171
212
 
172
- /** `--all`: update every KNOWN runtime that is actually installed (manifest present);
173
- * the rest are reported as not-installed, never an error. */
213
+ /** `--all`: update every runtime that is actually installed — the disk scan (manifest
214
+ * present) UNION the built-in registry (В52: a custom --package runtime has no
215
+ * built-in mapping but IS installed; it must not fall out of the cascade). Known
216
+ * runtimes without a manifest are reported as not-installed, never an error. */
174
217
  export async function updateAllRuntimes(opts: Omit<UpdateRuntimeOptions, 'runtime'> = {}): Promise<UpdateRuntimeResult[]> {
175
218
  const env = opts.env ?? process.env
176
219
  const out: UpdateRuntimeResult[] = []
177
- for (const rt of Object.keys(RUNTIME_PACKAGES) as Runtime[]) {
220
+ const known = new Set<Runtime>([...(Object.keys(RUNTIME_PACKAGES) as Runtime[]), ...installedRuntimes(env)])
221
+ for (const rt of known) {
178
222
  if (!readRuntimeManifest(rt, { env })) {
179
223
  out.push({ runtime: rt, state: 'not-installed', peers: [], restarted: [], detail: 'not installed — skipped' })
180
224
  continue
@@ -41,6 +41,12 @@ function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
41
41
  )
42
42
  }
43
43
 
44
+ /** Absolute screen reset written on every terminal resize: erase-all + cursor-home.
45
+ * Goes through Ink's OWN write path (useStdout().write → writeToStdout), which
46
+ * erases Ink's frame, writes these bytes, then REPLAYS the last frame with reset
47
+ * erase bookkeeping — so the replay lands at a KNOWN absolute origin (row 1). */
48
+ const RESIZE_SCREEN_RESET = '\x1b[2J\x1b[H'
49
+
44
50
  export function DashboardApp({
45
51
  env,
46
52
  onAction,
@@ -49,7 +55,7 @@ export function DashboardApp({
49
55
  onAction: (a: DashAction) => void
50
56
  }): React.ReactElement {
51
57
  const { exit } = useApp()
52
- const { stdout } = useStdout()
58
+ const { stdout, write } = useStdout()
53
59
  const [rows, setRows] = useState<PeerListing[]>(() => takePeersSnapshot(env))
54
60
  const [host, setHost] = useState<HostHeader | null>(null)
55
61
  const [now, setNow] = useState<number>(() => Date.now())
@@ -95,12 +101,26 @@ export function DashboardApp({
95
101
  }, [env])
96
102
  useEffect(() => {
97
103
  if (!stdout) return
98
- const onResize = (): void => setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
104
+ const onResize = (): void => {
105
+ // Ink erases frames by RELATIVE cursor math (cursor-up × line-count), and a real
106
+ // terminal displaces alt-screen content on window resize (Apple Terminal shifts it
107
+ // on height changes; a width change can re-wrap) — after which that relative erase
108
+ // misses rows and stale frame fragments survive FOREVER (live incident: a drag-
109
+ // resize stacked 9 footers; Ink itself full-clears only on width SHRINK). So on
110
+ // EVERY resize force an ABSOLUTE reset: wipe the screen + home the cursor, then let
111
+ // Ink replay the frame from that known origin. Written through Ink's write API —
112
+ // never raw to stdout behind its back (the 0.4.51 lesson): writeToStdout keeps its
113
+ // erase model in sync (clear → our bytes → replay with reset line-counts). This
114
+ // listener registers AFTER Ink's own resize handler (mount effect vs render), so
115
+ // the absolute reset is always the LAST word on the resize tick.
116
+ write(RESIZE_SCREEN_RESET)
117
+ setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
118
+ }
99
119
  stdout.on('resize', onResize)
100
120
  return () => {
101
121
  stdout.off('resize', onResize)
102
122
  }
103
- }, [stdout])
123
+ }, [stdout, write])
104
124
 
105
125
  const visible = filterRows(rows, filter)
106
126
  const cur = clampCursor(cursor, visible.length)
@@ -38,13 +38,25 @@ export const DASHBOARD_NOT_INTERACTIVE = -1
38
38
  /** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
39
39
  * bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
40
40
  * the module path marks the compiled standalone (existsSync is NO discriminator —
41
- * Bun intercepts bunfs reads). */
42
- export function resolveSelfCliArgv(execPath: string, selfPath: string): string[] {
43
- return selfPath.includes('$bunfs') ? [execPath] : [execPath, selfPath]
41
+ * Bun intercepts bunfs reads).
42
+ *
43
+ * Discriminate on the MODULE's OWN url — NEVER on a `../..`-navigated path. The
44
+ * compiled bundle lives at `file:///$bunfs/root/<bundle>` (depth 2), so navigating
45
+ * two levels up ESCAPES /$bunfs and URL-normalization silently drops the marker
46
+ * (proven: compiled probe → `new URL('../../cli/index.ts', import.meta.url)` =
47
+ * `/cli/index.ts`). The prior code passed that navigated path here, so the COMPILED
48
+ * dashboard took the src branch and spawned `<binary> /cli/index.ts attach <peer>`
49
+ * → unknown verb → full usage + exit 2 (live incident: Enter→attach dead in
50
+ * `iapeer list`; from-src runs masked it — the navigated path is real there). The
51
+ * cli path is resolved lazily and ONLY on the src branch. */
52
+ export function resolveSelfCliArgv(execPath: string, moduleUrl: string): string[] {
53
+ return moduleUrl.includes('$bunfs')
54
+ ? [execPath]
55
+ : [execPath, fileURLToPath(new URL('../../cli/index.ts', moduleUrl))]
44
56
  }
45
57
 
46
58
  function selfCliArgv(): string[] {
47
- return resolveSelfCliArgv(process.execPath, fileURLToPath(new URL('../../cli/index.ts', import.meta.url)))
59
+ return resolveSelfCliArgv(process.execPath, import.meta.url)
48
60
  }
49
61
 
50
62
  export interface DashboardOptions {
@@ -18,10 +18,11 @@ import { spawnSync } from 'child_process'
18
18
  import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
19
19
  import { homedir } from 'os'
20
20
  import { join } from 'path'
21
- import { DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
22
- import { iapeerBinPath } from '../install/index.ts'
21
+ import { buildProcessAddress, DAEMON_PLIST_LABEL, IapError } from '../core/index.ts'
22
+ import { iapeerBinPath, iapeerHealthyStampPath } from '../install/index.ts'
23
23
  import { SIGNING_IDENTITY_CN } from '../install/signing.ts'
24
24
  import { daemonPlistPath } from '../daemon/main.ts'
25
+ import { hostSessionAlive, killPtyHost, listHostedPeers } from '../launch/ptyHost.ts'
25
26
  import { isFoundationOwnedPlist, launchAgentsDir } from '../launch/launchd.ts'
26
27
  import { resolveGlobalRoot } from '../storage/index.ts'
27
28
 
@@ -32,6 +33,7 @@ export type UninstallActionKind =
32
33
  | 'remove-file'
33
34
  | 'remove-dir'
34
35
  | 'bootout-remove-plist'
36
+ | 'stop-pty-sessions'
35
37
  | 'strip-profile-lines'
36
38
  | 'remove-keychain-identity'
37
39
 
@@ -61,6 +63,11 @@ export interface UninstallOptions {
61
63
  removeCodesignIdentity?: boolean
62
64
  /** Injected sys runner (launchctl / security) for hermetic tests. */
63
65
  run?: SysRunner
66
+ /** В56 — how long to wait for a booted-out launchd job to actually unload before
67
+ * declaring it stuck (default 10 s; tests inject a short value). */
68
+ launchdWaitMs?: number
69
+ /** В55 — how long to wait for SIGTERM'd pty sessions to die (default 8 s). */
70
+ sessionWaitMs?: number
64
71
  }
65
72
 
66
73
  const defaultRun: SysRunner = (cmd, args) => {
@@ -153,8 +160,26 @@ export function planUninstall(opts: UninstallOptions = {}): UninstallPlan {
153
160
  })
154
161
  }
155
162
 
163
+ // В55 — live warm-on-demand pty sessions (detached supervisors, NO plist: the
164
+ // launchd guard above cannot see them). Their run-dir/state live INSIDE ~/.iapeer —
165
+ // removing the tree under ~30 running agent processes leaves an orphaned live
166
+ // fleet working out of deleted directories. Stop them FIRST (before remove-dir).
167
+ const hosted = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
168
+ items.push({
169
+ what: `live pty sessions (${hosted.length ? hosted.join(', ') : 'none'})`,
170
+ action: 'stop-pty-sessions',
171
+ present: hosted.length > 0,
172
+ detail: hosted.length ? 'will be stopped before ~/.iapeer is removed' : undefined,
173
+ })
174
+
156
175
  items.push({ what: 'binary ~/.local/bin/iapeer', action: 'remove-file', path: bin, present: existsSync(bin) })
157
176
  items.push({ what: 'previous binary (.prev)', action: 'remove-file', path: prev, present: existsSync(prev) })
177
+ items.push({
178
+ what: 'healthy-stamp (.healthy)',
179
+ action: 'remove-file',
180
+ path: iapeerHealthyStampPath(env),
181
+ present: existsSync(iapeerHealthyStampPath(env)),
182
+ })
158
183
  items.push({ what: 'state + config ~/.iapeer', action: 'remove-dir', path: root, present: existsSync(root) })
159
184
 
160
185
  for (const pf of profileFiles(env)) {
@@ -181,8 +206,47 @@ export interface UninstallResult {
181
206
  refused?: { reason: string; foreignLabels: string[] }
182
207
  }
183
208
 
184
- /** Remove a foundation install per the plan. Refuses if a foreign fleet is present. */
185
- export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
209
+ const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms))
210
+
211
+ /** В56 — poll `launchctl print gui/<uid>/<label>` until the job is GONE (print exits
212
+ * non-zero) or the deadline passes. bootout is ASYNC in launchd: firing rm/rmdir the
213
+ * moment it returns races the dying daemon's shutdown writes (recreated fragments)
214
+ * and, on a bootout failure, leaves an orphaned KeepAlive job holding the port while
215
+ * the item reads "removed". Same wait-for-gone contract the update cycle uses. */
216
+ async function waitJobGone(run: SysRunner, uid: number, label: string, timeoutMs = 10_000): Promise<boolean> {
217
+ const deadline = Date.now() + timeoutMs
218
+ for (;;) {
219
+ if (run('launchctl', ['print', `gui/${uid}/${label}`]).status !== 0) return true // not loaded → gone
220
+ if (Date.now() >= deadline) return false
221
+ await sleep(250)
222
+ }
223
+ }
224
+
225
+ /** В55 — stop every live warm-on-demand pty session (SIGTERM the supervisor daemon,
226
+ * wait for its pidfile-liveness to drop). Best-effort per session; reports laggards. */
227
+ async function stopHostedSessions(env: NodeJS.ProcessEnv, timeoutMs = 8000): Promise<{ stopped: string[]; stuck: string[] }> {
228
+ const identities = listHostedPeers(env).map(h => buildProcessAddress(h.runtime, h.personality))
229
+ for (const id of identities) {
230
+ try {
231
+ killPtyHost(id, env)
232
+ } catch {
233
+ /* verified-dead / permission — the wait below reports it */
234
+ }
235
+ }
236
+ const deadline = Date.now() + timeoutMs
237
+ let pending = identities
238
+ while (pending.length > 0 && Date.now() < deadline) {
239
+ await sleep(250)
240
+ pending = pending.filter(id => hostSessionAlive(id, env))
241
+ }
242
+ return { stopped: identities.filter(id => !pending.includes(id)), stuck: pending }
243
+ }
244
+
245
+ /** Remove a foundation install per the plan. Refuses if a foreign fleet is present.
246
+ * Ordering contract: live pty sessions are stopped and launchd jobs are FULLY
247
+ * unloaded (wait-for-gone) BEFORE ~/.iapeer is removed — a failed unload FAILS the
248
+ * remove-dir step (never delete the tree out from under a still-running daemon). */
249
+ export async function executeUninstall(opts: UninstallOptions = {}): Promise<UninstallResult> {
186
250
  const env = opts.env ?? process.env
187
251
  assertSandboxSafe(env)
188
252
  const run = opts.run ?? defaultRun
@@ -194,6 +258,9 @@ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
194
258
  }
195
259
 
196
260
  const uid = process.getuid?.() ?? 0
261
+ // Set when a launchd job survived bootout (В56) — ~/.iapeer must NOT be removed
262
+ // from under a job launchd may respawn (KeepAlive) into the deleted tree.
263
+ let jobStillLoaded: string | undefined
197
264
  for (const item of plan.items) {
198
265
  if (!item.present) {
199
266
  res.skipped.push(item.what)
@@ -203,16 +270,37 @@ export function executeUninstall(opts: UninstallOptions = {}): UninstallResult {
203
270
  switch (item.action) {
204
271
  case 'bootout-remove-plist': {
205
272
  const label = (item.path ?? '').replace(/\.plist$/, '').split('/').pop() ?? ''
206
- run('launchctl', ['bootout', `gui/${uid}/${label}`]) // best-effort (not-loaded → non-zero, fine)
273
+ run('launchctl', ['bootout', `gui/${uid}/${label}`]) // not-loaded → non-zero, benign (wait-for-gone verifies)
274
+ if (!(await waitJobGone(run, uid, label, opts.launchdWaitMs))) {
275
+ jobStillLoaded = label
276
+ res.failed.push({ what: item.what, detail: `job ${label} still loaded after bootout (wait-for-gone timeout)` })
277
+ continue // plist kept — the operator can bootout manually and re-run
278
+ }
207
279
  if (item.path) rmSync(item.path, { force: true })
208
280
  break
209
281
  }
282
+ case 'stop-pty-sessions': {
283
+ const r = await stopHostedSessions(env, opts.sessionWaitMs)
284
+ if (r.stuck.length > 0) {
285
+ res.failed.push({ what: item.what, detail: `still alive after SIGTERM+wait: ${r.stuck.join(', ')}` })
286
+ continue
287
+ }
288
+ break
289
+ }
210
290
  case 'remove-file':
211
291
  if (item.path) rmSync(item.path, { force: true })
212
292
  break
213
- case 'remove-dir':
293
+ case 'remove-dir': {
294
+ if (jobStillLoaded) {
295
+ res.failed.push({
296
+ what: item.what,
297
+ detail: `NOT removed — launchd job ${jobStillLoaded} is still loaded (KeepAlive would respawn into a deleted tree); bootout it manually, then re-run uninstall`,
298
+ })
299
+ continue
300
+ }
214
301
  if (item.path) rmSync(item.path, { recursive: true, force: true })
215
302
  break
303
+ }
216
304
  case 'strip-profile-lines':
217
305
  if (item.path) stripInstallerLines(item.path)
218
306
  break
@@ -29,13 +29,14 @@
29
29
  // unit-testable with no network and no launchctl; the defaults are the real impls.
30
30
 
31
31
  import { spawnSync } from 'child_process'
32
- import { mkdtempSync, readdirSync, rmSync } from 'fs'
32
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'fs'
33
33
  import { connect } from 'net'
34
34
  import { tmpdir } from 'os'
35
35
  import { join } from 'path'
36
36
  import { isInfraRuntime } from '../core/constants.ts'
37
37
  import { IapError } from '../core/errors.ts'
38
38
  import { IAPEER_VERSION } from '../core/version.ts'
39
+ import { stampBinaryHealthy } from '../install/index.ts'
39
40
  import { readPeersIndex } from '../registry/index.ts'
40
41
  import {
41
42
  cycleDaemon,
@@ -46,7 +47,7 @@ import {
46
47
  type DaemonRestartResult,
47
48
  type LaunchdJobCycleResult,
48
49
  } from '../launch/launchd.ts'
49
- import { defaultDaemonSocketPath } from '../daemon/index.ts'
50
+ import { daemonDiscoveryPath, defaultDaemonSocketPath } from '../daemon/index.ts'
50
51
  // Type-only (erased at runtime → no import cycle): the cascade tail renders these
51
52
  // shapes but receives the component-updaters as injected deps.
52
53
  import type { UpdateRuntimeResult } from '../runtime/update.ts'
@@ -76,6 +77,15 @@ export interface UpdateDeps {
76
77
  /** Re-register loaded foundation-owned infra launchd jobs whose plists run the
77
78
  * installed iapeer binary (default: registry scan + sentinel guard). */
78
79
  recycleInfraJobs?: (env: NodeJS.ProcessEnv) => InfraJobRecycleResult[]
80
+ /** В54 — post-restart health gate (default: waitForDaemonHealthy). Infra jobs are
81
+ * recycled ONLY after this passes — never onto an unproven binary. */
82
+ waitHealthy?: (env: NodeJS.ProcessEnv) => Promise<HealthResult>
83
+ /** В53 — the LIVE daemon's version (default: read router.json's `version`), or null
84
+ * when no live daemon / unreadable / pre-В53 daemon (no version field). */
85
+ liveDaemonVersion?: (env: NodeJS.ProcessEnv) => string | null
86
+ /** В50 — record the installed binary as known-good after a passed health-check
87
+ * (default: stampBinaryHealthy; skipped under the test sandbox). */
88
+ stampHealthy?: (env: NodeJS.ProcessEnv) => boolean
79
89
  }
80
90
 
81
91
  export interface InfraJobRecycleResult {
@@ -96,8 +106,15 @@ export interface UpdateResult {
96
106
  to?: string
97
107
  /** What happened to the live daemon (only on 'updated'). */
98
108
  daemon?: DaemonRestartResult['state']
109
+ /** Post-restart health verdict (set only when the daemon was restarted): true =
110
+ * socket accepting (infra recycled + binary stamped known-good), false = did NOT
111
+ * come up (the rollback cue). Undefined when there was nothing to health-check. */
112
+ healthy?: boolean
99
113
  /** Foundation-owned infra jobs recycled after the binary swap (only on 'updated'). */
100
114
  infra?: InfraJobRecycleResult[]
115
+ /** В53 — set when the "already-latest" gate found the LIVE daemon running an older
116
+ * version (interrupted prior update) and healed it with a restart. */
117
+ healedStaleDaemon?: string
101
118
  /** Human reason on 'failed' / extra detail on a daemon restart hiccup. */
102
119
  reason?: string
103
120
  }
@@ -255,13 +272,57 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
255
272
  return out
256
273
  }
257
274
 
275
+ /** В53 default — the live daemon's version from the discovery file (router.json).
276
+ * Null when there is no live daemon (file absent — it is removed on clean
277
+ * shutdown), the file is unreadable, or the daemon predates the version field. */
278
+ function defaultLiveDaemonVersion(env: NodeJS.ProcessEnv): string | null {
279
+ try {
280
+ const raw = JSON.parse(readFileSync(daemonDiscoveryPath({ env }), 'utf8')) as { version?: unknown }
281
+ return typeof raw.version === 'string' && SEMVER_RE.test(raw.version) ? raw.version : null
282
+ } catch {
283
+ return null
284
+ }
285
+ }
286
+
287
+ /** Restart → health-gate → (recycle infra + stamp known-good) — the SETTLE half every
288
+ * update path shares. В54: infra jobs are recycled ONLY after the daemon proved
289
+ * healthy — a broken release must take down the daemon alone, never also flip
290
+ * telegram/notifier onto the broken binary (rollback re-cycles them after restoring
291
+ * `.prev`). В50: the healthy binary is stamped known-good so the NEXT install may
292
+ * refresh `.prev` from it. 'not-loaded' (no daemon on this host) keeps the legacy
293
+ * behavior — recycle loaded infra jobs, nothing to health-check or stamp. */
294
+ async function settleDaemon(
295
+ d: DaemonRestartResult,
296
+ env: NodeJS.ProcessEnv,
297
+ deps: UpdateDeps,
298
+ ): Promise<{ healthy?: boolean; infra: InfraJobRecycleResult[]; reason?: string }> {
299
+ if (d.state === 'failed') {
300
+ return { infra: [], reason: `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() }
301
+ }
302
+ const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
303
+ if (d.state !== 'restarted') {
304
+ return { infra: recycleInfra(env) } // not-loaded: no live daemon → no health verdict, no stamp
305
+ }
306
+ const h = await (deps.waitHealthy ?? ((e: NodeJS.ProcessEnv) => waitForDaemonHealthy({ env: e })))(env)
307
+ if (!h.healthy) {
308
+ return { healthy: false, infra: [], reason: `daemon is NOT healthy after restart (${h.detail ?? 'socket not accepting'})` }
309
+ }
310
+ // Default stamp is skipped under the test sandbox (a real stamp writes next to the
311
+ // REAL binary); tests that care inject stampHealthy.
312
+ const stamp = deps.stampHealthy ?? ((e: NodeJS.ProcessEnv) => (e.IAPEER_TEST_SANDBOX === '1' ? false : stampBinaryHealthy(e)))
313
+ stamp(env)
314
+ return { healthy: true, infra: recycleInfra(env) }
315
+ }
316
+
258
317
  /**
259
318
  * Update the foundation to the latest published version (cloud-only) and restart
260
319
  * the daemon onto it. Idempotent + version-gated: a no-op "already-latest" when the
261
- * installed version equals the published one (unless `force`). Pure-ish — the three
262
- * effects are injected, so this is fully unit-testable.
320
+ * installed version equals the published one (unless `force`) AND the live daemon
321
+ * actually runs it (В53 — an interrupted prior update left the binary swapped but
322
+ * the daemon on old code; that heals with a restart, not a false no-op). Pure-ish —
323
+ * every effect is injected, so this is fully unit-testable.
263
324
  */
264
- export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
325
+ export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult> {
265
326
  const env = deps.env ?? process.env
266
327
  const from = deps.currentVersion ?? IAPEER_VERSION
267
328
  const resolve = deps.resolveVersion ?? defaultResolveVersion
@@ -281,7 +342,26 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
281
342
  }
282
343
  }
283
344
  if (desired === from && !deps.force) {
284
- return { status: 'already-latest', from, latest: desired }
345
+ // В53 — the binary is at the target, but is the LIVE daemon running it? A prior
346
+ // update that swapped the binary and then died before/inside cycleDaemon leaves
347
+ // the fleet on the old core FOREVER while every retry says "already-latest".
348
+ const live = (deps.liveDaemonVersion ?? defaultLiveDaemonVersion)(env)
349
+ if (!live || live === from) {
350
+ return { status: 'already-latest', from, latest: desired }
351
+ }
352
+ const d = (deps.restartDaemon ?? cycleDaemon)(env)
353
+ const settled = await settleDaemon(d, env, deps)
354
+ return {
355
+ status: 'updated',
356
+ from: live,
357
+ to: desired,
358
+ latest: desired,
359
+ daemon: d.state,
360
+ healthy: settled.healthy,
361
+ infra: settled.infra,
362
+ healedStaleDaemon: live,
363
+ reason: settled.reason,
364
+ }
285
365
  }
286
366
 
287
367
  const runInstall = deps.runInstall ?? defaultRunInstall
@@ -300,21 +380,17 @@ export function updateIapeer(deps: UpdateDeps = {}): UpdateResult {
300
380
  }
301
381
  }
302
382
 
303
- const restart = deps.restartDaemon ?? cycleDaemon
304
- const d = restart(env)
305
- const recycleInfra = deps.recycleInfraJobs ?? recycleFoundationOwnedInfraJobs
306
- // If the daemon re-registration itself failed, do NOT widen the blast radius by
307
- // cycling infra jobs onto a binary whose daemon could not come up. Rollback will
308
- // re-cycle them after restoring `.prev`.
309
- const infra = d.state === 'failed' ? [] : recycleInfra(env)
383
+ const d = (deps.restartDaemon ?? cycleDaemon)(env)
384
+ const settled = await settleDaemon(d, env, deps)
310
385
  return {
311
386
  status: 'updated',
312
387
  from,
313
388
  to: desired,
314
389
  latest: desired,
315
390
  daemon: d.state,
316
- infra,
317
- reason: d.state === 'failed' ? `binary updated but daemon restart failed: ${d.detail ?? ''}`.trim() : undefined,
391
+ healthy: settled.healthy,
392
+ infra: settled.infra,
393
+ reason: settled.reason,
318
394
  }
319
395
  }
320
396