@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 +24 -1
- package/package.json +1 -1
- package/src/connect/index.ts +17 -0
- package/src/daemon/index.ts +9 -0
- package/src/daemon/main.ts +5 -0
- package/src/transport/index.ts +58 -0
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.
|
|
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
package/src/connect/index.ts
CHANGED
|
@@ -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)
|
package/src/daemon/index.ts
CHANGED
|
@@ -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', () => {
|
package/src/daemon/main.ts
CHANGED
|
@@ -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
|
package/src/transport/index.ts
CHANGED
|
@@ -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:
|