@agfpd/iapeer 0.4.24 → 0.4.25

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/CHANGELOG.md CHANGED
@@ -10,6 +10,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.25] - 2026-06-22
14
+
15
+ ### Fixed
16
+
17
+ - **`connect telegram` now eager-reindexes the registry** (#1). `interface bot` merges
18
+ interfaces.telegram into the per-cwd profile (source of truth), but the derived registry
19
+ (peers-profiles.json) is a projection that only refreshed on specific verbs — so until
20
+ then the peer's telegram FACE was invisible to the sender-guard (`hasTelegramPresence`
21
+ reads the REGISTRY passport), **blocking the peer's outbound telegram** (observed live:
22
+ impact-finder "no telegram face"). connect now calls `reindexFromLocals` after a
23
+ successful `interface bot` (best-effort), so the face lands in the registry immediately —
24
+ no manual `iapeer verify --fix`.
25
+ - **Launchd-revive delivery retry** (#2): a MISS on a launchd-managed target — which the
26
+ daemon can't wake (H4) but launchd KeepAlive revives in ~1s — no longer fails terminally
27
+ in ~16ms. During a router restart (on `connect` / `iapeer update`) an in-flight delivery
28
+ was lost (observed: `natalya→arthur ok=false ms=16` mid-restart — a message to a HUMAN).
29
+ routeSend now retries-resolve for a bounded window (`IAP_LAUNCHD_REVIVE_GRACE_MS`, ~4s)
30
+ and delivers the instant the target revives; it re-resolves each poll (verify-before-act,
31
+ so the ok reflects a CONFIRMED delivery — no false-OK), and a genuinely-down peer still
32
+ fails LOUD and retryable (never silent). `isLaunchdManaged` injected via deps (no
33
+ transport→lifecycle coupling).
34
+
13
35
  ## [0.4.24] - 2026-06-22
14
36
 
15
37
  ### Fixed
@@ -366,7 +388,8 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
366
388
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
367
389
  (timer = cron, watcher = event) are all first-class and addressed the same way.
368
390
 
369
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.24...HEAD
391
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.25...HEAD
392
+ [0.4.25]: https://github.com/agfpd/iapeer/compare/v0.4.24...v0.4.25
370
393
  [0.4.24]: https://github.com/agfpd/iapeer/compare/v0.4.23...v0.4.24
371
394
  [0.4.23]: https://github.com/agfpd/iapeer/compare/v0.4.22...v0.4.23
372
395
  [0.4.22]: https://github.com/agfpd/iapeer/compare/v0.4.21...v0.4.22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.24",
3
+ "version": "0.4.25",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -25,6 +25,7 @@ import { spawnSync } from 'child_process'
25
25
  import { normalizeIntelligenceValue } from '../core/constants.ts'
26
26
  import { runtimeRoot } from '../storage/index.ts'
27
27
  import { findPeer, readPeersIndex } from '../registry/index.ts'
28
+ import { reindexFromLocals } from '../identity/profileStandard.ts'
28
29
  import { readRuntimeManifest } from '../runtime/index.ts'
29
30
 
30
31
  export interface TgRunResult {
@@ -229,6 +230,22 @@ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<Con
229
230
  return { state: 'interface-failed', peer, username, detail: (iface.stderr || iface.stdout || `exit ${iface.status}`).trim() }
230
231
  }
231
232
 
233
+ // (2b) EAGER reindex — `interface bot` merged interfaces.telegram into the per-cwd
234
+ // profile (source of truth), but the derived registry (peers-profiles.json) is a
235
+ // PROJECTION that only refreshes on specific verbs (verify --fix, default-runtime…).
236
+ // Until then the peer's telegram FACE is INVISIBLE to the sender-guard
237
+ // (hasTelegramPresence reads the REGISTRY passport) — so the peer's OUTBOUND telegram
238
+ // is blocked ("no telegram face") + list/attach are stale. Project it now (same
239
+ // reindexFromLocals the default-runtime verb uses). Best-effort: a reindex hiccup must
240
+ // not fail a connect whose profile + bot are already in place (registry catches up on
241
+ // the next reindex verb). Runs on EVERY connect, incl. the idempotent no-op below — so
242
+ // a registry that drifted on a prior (pre-fix) connect self-heals here too.
243
+ try {
244
+ await reindexFromLocals({ env })
245
+ } catch {
246
+ /* registry lazily catches up on the next reindex verb; profile + bot are correct */
247
+ }
248
+
232
249
  // (3) Idempotency gate: the SAME token leaves bots/<alias>/.env byte-stable →
233
250
  // the live poller already loaded these credentials → NO restart, clean no-op.
234
251
  const after = readBotEnv(alias, env)
@@ -194,6 +194,10 @@ export interface CallToolDeps {
194
194
  noteLiveTopic?: (identity: string, topic: string) => void
195
195
  /** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
196
196
  composerQueue?: ComposerQueueRouteDeps
197
+ /** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
198
+ * launchd-revive delivery retry). Injected so the daemon never imports lifecycle;
199
+ * the production main wires the real isLaunchdManaged. */
200
+ isLaunchdManaged?: (personality: string) => boolean
197
201
  }
198
202
 
199
203
  export async function callTool(
@@ -216,6 +220,7 @@ export async function callTool(
216
220
  ephemeral: deps.ephemeral,
217
221
  noteLiveTopic: deps.noteLiveTopic,
218
222
  composerQueue: deps.composerQueue,
223
+ isLaunchdManaged: deps.isLaunchdManaged,
219
224
  })
220
225
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
221
226
  // to lifecycle.log) — metadata only, never the body. Both branches of the
@@ -392,6 +397,9 @@ export interface StartDaemonOptions extends StorageOptions {
392
397
  * senders on shutdown/restart.
393
398
  */
394
399
  composerQueue?: ComposerQueueRouteDeps
400
+ /** H4 launchd-managed detector — see CallToolDeps.isLaunchdManaged / the launchd-revive
401
+ * delivery retry. OFF by default (library/tests); production main wires the real one. */
402
+ isLaunchdManaged?: (personality: string) => boolean
395
403
  /**
396
404
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
397
405
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -426,6 +434,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
426
434
  ephemeral: opts.ephemeral,
427
435
  noteLiveTopic: opts.noteLiveTopic,
428
436
  composerQueue: opts.composerQueue,
437
+ isLaunchdManaged: opts.isLaunchdManaged,
429
438
  })
430
439
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
431
440
  res.on('close', () => {
@@ -35,6 +35,7 @@ import {
35
35
  drainEphemeralQueue,
36
36
  enqueueEphemeralTask,
37
37
  isEphemeralPeer,
38
+ isLaunchdManaged,
38
39
  addTopic,
39
40
  loadLifecycleConfig,
40
41
  processEagerRelaunches,
@@ -332,6 +333,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
332
333
  // Busy-human-composer queue: fast queued ack, async drain, fail all pending
333
334
  // on daemon close/update so a queued envelope is never silently lost.
334
335
  composerQueue,
336
+ // H4 launchd-revive delivery retry (see RouteDeps.isLaunchdManaged): a MISS on a
337
+ // launchd-managed target (the daemon can't wake it, but KeepAlive revives it) → retry-
338
+ // resolve for a bounded window instead of failing in ~16ms — bridges a router restart.
339
+ isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
335
340
  supervise: {
336
341
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
337
342
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -176,6 +176,17 @@ function hostLivenessGraceMs(): number {
176
176
  return Number.isFinite(n) && n >= 0 ? n : livenessGraceMs()
177
177
  }
178
178
 
179
+ // Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
180
+ // but launchd KeepAlive WILL revive (router restart / crash-revive window). routeSend
181
+ // re-resolves for up to this window before failing, bridging the gap (silent-loss guard).
182
+ // Env-tunable; default ~4s (a bootout→bootstrap router restart settles well under it).
183
+ const LAUNCHD_REVIVE_POLL_MS = 300
184
+ function launchdReviveGraceMs(): number {
185
+ const raw = process.env.IAP_LAUNCHD_REVIVE_GRACE_MS
186
+ const n = raw === undefined ? NaN : Number(raw)
187
+ return Number.isFinite(n) && n >= 0 ? n : 4000
188
+ }
189
+
179
190
  // ─── Warm delivery (pty-only — supervisor socket) ────────────────────────────────────────────────
180
191
 
181
192
  /** Test seam for `deliverWarm`. Production defaults route on the real supervisor; tests inject a
@@ -793,6 +804,17 @@ export interface RouteDeps {
793
804
  * imports lifecycle (layering — main.ts is where the two meet); call sites
794
805
  * swallow hook errors (a hook must never fail a delivered message). */
795
806
  noteLiveTopic?: (identity: string, topic: string) => void
807
+ /** H4 launchd-managed detector (injected so transport never imports lifecycle —
808
+ * main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
809
+ * almost always a TRANSIENT restart window (router restart on connect / `iapeer
810
+ * update`, or a crash that KeepAlive is reviving), NOT a real outage: the daemon
811
+ * can't wake it (H4) but launchd WILL revive it in ~1s. So routeSend retries the
812
+ * RESOLVE for a bounded window before failing, bridging the gap (prevents the
813
+ * silent-loss class: an in-flight delivery — esp. to a HUMAN over a telegram router —
814
+ * dropped during a restart). Absent → no retry (legacy behaviour). */
815
+ isLaunchdManaged?: (personality: string) => boolean
816
+ /** Injectable async sleep for the launchd-revive retry poll (tests). Default: setTimeout. */
817
+ sleep?: (ms: number) => Promise<void>
796
818
  }
797
819
 
798
820
  /** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
@@ -1159,6 +1181,42 @@ export async function routeSend(
1159
1181
 
1160
1182
  // MISS — peer offline.
1161
1183
  if (!deps.wake) return target // Ф1: explicit offline, no wake
1184
+ // LAUNCHD-REVIVE RETRY: a launchd-managed target the daemon can't wake (H4) but launchd
1185
+ // KeepAlive WILL revive — a MISS here is almost always a transient RESTART WINDOW (router
1186
+ // restart on connect / `iapeer update`, or a crash-revive), not a real outage. Wake would
1187
+ // just refuse (launchd-managed) and the delivery would fail in ~16ms — losing an in-flight
1188
+ // message (observed: natalya→arthur ok=false ms=16 during a connect router restart; a
1189
+ // message to a HUMAN, the silent-loss-adjacent class). Bridge it: re-resolve for a bounded
1190
+ // window and deliver the instant it revives. Each poll RE-RESOLVES the live target
1191
+ // (verify-before-act) → the ok reflects a CONFIRMED delivery (no false-OK); a genuinely
1192
+ // down peer still fails LOUD after the window (retryable, never silent).
1193
+ if (deps.isLaunchdManaged?.(personality)) {
1194
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
1195
+ const reviveDeadline = monotonicMs() + launchdReviveGraceMs()
1196
+ while (monotonicMs() < reviveDeadline) {
1197
+ await sleep(LAUNCHD_REVIVE_POLL_MS)
1198
+ const revived = resolvePeerDeliveryTarget(personality, runtime, peer)
1199
+ if (!revived.ok) continue // not back yet — keep polling until the deadline
1200
+ if (revived.value.address === caller.address) return err('cannot send to self')
1201
+ const reviveGuard = telegramSenderGuard(caller, revived.value.runtime, personality)
1202
+ if (!reviveGuard.ok) return reviveGuard
1203
+ const delivered = await deliverWarm(revived.value, envelope, peer.cwd)
1204
+ if (delivered.ok) {
1205
+ noteTopic(deps, revived.value.address, topic)
1206
+ return ok({
1207
+ ok: true,
1208
+ delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
1209
+ woke: false,
1210
+ ts: new Date().toISOString(),
1211
+ })
1212
+ }
1213
+ // resolved but the deliver didn't land (session still settling) → keep polling
1214
+ }
1215
+ return err(
1216
+ `peer "${personality}" (launchd-managed) offline and did not revive within ${launchdReviveGraceMs()}ms ` +
1217
+ `(restart window outlasted the grace?) — message NOT delivered; retry`,
1218
+ )
1219
+ }
1162
1220
  const woke = await deps.wake({ personality, runtime, topic, task: envelope })
1163
1221
  if (woke.status === 'FAILED') {
1164
1222
  // C1 — a durably STOPPED peer is a deliberate halt, not a transient miss: