@agfpd/iapeer 0.4.103 → 0.4.105
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/docs/08-peer-management.md +2 -0
- package/docs/09-runtimes.md +2 -0
- package/docs/10-messaging.md +1 -1
- package/docs/ru/09-/321/200/320/260/320/275/321/202/320/260/320/271/320/274/321/213.md +2 -0
- package/docs/ru/10-/320/276/320/261/320/274/320/265/320/275-/321/201/320/276/320/276/320/261/321/211/320/265/320/275/320/270/321/217/320/274/320/270.md +1 -1
- package/package.json +1 -1
- package/src/daemon/main.ts +32 -1
- package/src/daemon/profilesync.ts +112 -0
- package/src/transport/index.ts +17 -13
|
@@ -55,6 +55,8 @@ iapeer verify --fix # repair
|
|
|
55
55
|
|
|
56
56
|
`verify` checks two things: profiles' conformance to the standard, and divergences between the registry and the local profiles. With `--fix` the command self-heals the registry from the profiles (and migrates profiles from the old runtime-field format to the current one along the way). It's the standard way to put the registry in order if it's gone out of sync.
|
|
57
57
|
|
|
58
|
+
**The daemon reconciles continuously (0.4.104).** The local `peer-profile.json` is the source of truth and the registry only its projection — but until 0.4.104 a direct edit of the source (the owner's legitimate move) propagated only when some reindexing verb happened to run afterwards; the router reads the INDEX per-request, so a stale projection mis-routed deliveries (measured live 17.07.2026: a peer's local profile said `default_runtime: claude` for days while the index kept `codex`, and deliveries went to a mute codex session). The daemon now runs a profile-sync sweep (`daemon/profilesync.ts`, 60 s, mtime/set-gated — steady state is stats only, zero writes): any change to a registered peer's local profile triggers the same locked `reindexFromLocals` the verbs use, and a boot sweep heals drift accumulated while the daemon was down. Heals are logged to `lifecycle.log` as `profile-reindex` events. `verify` remains the read-only report and the manual lever; the daemon closes the propagation gap.
|
|
59
|
+
|
|
58
60
|
## Stop and start: stop, start
|
|
59
61
|
|
|
60
62
|
```bash
|
package/docs/09-runtimes.md
CHANGED
|
@@ -67,6 +67,8 @@ iapeer default-runtime codex --all # all peers
|
|
|
67
67
|
|
|
68
68
|
Switches the primary runtime — where routing goes and which runtime comes up first. A bulk `--all` switch is exactly the "transplant" of the whole team from one agent runtime to another; it's symmetric back (`default-runtime claude --all`). The command refuses if the peer doesn't declare that runtime (run `add-runtime` first), and skips infrastructure peers. The registry is fixed in the same command, so routing switches at once.
|
|
69
69
|
|
|
70
|
+
The routing choice is strict: a send without an explicit `runtime` always targets `default_runtime`. A live session on another declared runtime does not capture that send; if the default session is asleep, the daemon wakes it. Passing `runtime` explicitly remains the way to target a non-default surface.
|
|
71
|
+
|
|
70
72
|
## Connecting Telegram: connect
|
|
71
73
|
|
|
72
74
|
```bash
|
package/docs/10-messaging.md
CHANGED
|
@@ -25,7 +25,7 @@ The `--from` flag sets the sender (`<runtime>-<name>`); by default it's the iden
|
|
|
25
25
|
|
|
26
26
|
## The delivery path
|
|
27
27
|
|
|
28
|
-
The daemon finds the recipient in the registry and looks at
|
|
28
|
+
The daemon finds the recipient in the registry and selects its endpoint first. With no explicit `runtime`, the endpoint is always the recipient's `default_runtime`; liveness of other declared runtimes does not change that choice. An explicit `runtime` selects that exact surface. The daemon then looks at the selected endpoint's state:
|
|
29
29
|
|
|
30
30
|
- **alive** — the daemon writes the message into the peer's session through the supervisor socket onto the session's input. The daemon waits for confirmation that the input was accepted (the input field cleared); the answer is `delivered`.
|
|
31
31
|
- **busy** (the agent is mid-turn, the input field isn't empty) — the message goes into the composer queue and is delivered as soon as the agent is free; the answer to the sender is `queued`. The queue waits up to 120 seconds, then force-delivers.
|
|
@@ -67,6 +67,8 @@ iapeer default-runtime codex --all # все пиры
|
|
|
67
67
|
|
|
68
68
|
Переключает основной рантайм — куда идёт маршрутизация и какой рантайм поднимается первым. Массовое переключение `--all` — это и есть «пересадка» всей команды с одного агентского рантайма на другой; обратно симметрично (`default-runtime claude --all`). Команда откажет, если пир не объявляет этот рантайм (сначала `add-runtime`), и пропустит инфраструктурных. Реестр чинится в той же команде, поэтому маршрутизация переключается сразу.
|
|
69
69
|
|
|
70
|
+
Выбор для маршрутизации строгий: отправка без явного `runtime` всегда целится в `default_runtime`. Живая сессия на другом объявленном рантайме не перехватывает такую отправку; если дефолтная сессия спит, демон будит её. Явный `runtime` по-прежнему выбирает конкретную недефолтную поверхность.
|
|
71
|
+
|
|
70
72
|
## Подключение Telegram: connect
|
|
71
73
|
|
|
72
74
|
```bash
|
|
@@ -25,7 +25,7 @@ iapeer send assistant --message "..." --topic "релиз" --from claude-reviewe
|
|
|
25
25
|
|
|
26
26
|
## Путь доставки
|
|
27
27
|
|
|
28
|
-
Демон находит получателя в реестре и смотрит на
|
|
28
|
+
Демон находит получателя в реестре и сначала выбирает поверхность доставки. Без явного `runtime` это всегда `default_runtime` адресата; живость других объявленных рантаймов выбор не меняет. Явный `runtime` выбирает ровно указанную поверхность. Затем демон смотрит на состояние выбранной поверхности:
|
|
29
29
|
|
|
30
30
|
- **живой** — демон пишет сообщение в сессию пира через сокет супервизора на ввод сессии. Демон ждёт подтверждения, что ввод принят (поле ввода очистилось); ответ — `доставлено`.
|
|
31
31
|
- **занят** (агент в середине хода, поле ввода не пустое) — сообщение встаёт в очередь композера и доставляется, как только агент освободится; ответ отправителю — `в очереди`. Очередь ждёт до 120 секунд, потом досылает принудительно.
|
package/package.json
CHANGED
package/src/daemon/main.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { appendDeliveryEvent } from './deliverylog.ts'
|
|
|
69
69
|
import { buildFleetHandler } from './fleet.ts'
|
|
70
70
|
import { NoticeBoard } from './notices.ts'
|
|
71
71
|
import { startMuteWatch } from './mutewatch.ts'
|
|
72
|
+
import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesync.ts'
|
|
72
73
|
import { startGoalWatch } from './goalwatch.ts'
|
|
73
74
|
import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
|
|
74
75
|
import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
|
|
@@ -348,6 +349,8 @@ export interface ConfiguredDaemonOptions {
|
|
|
348
349
|
goalWatchIntervalMs?: number
|
|
349
350
|
/** Turn-watch sweep cadence (default DEFAULT_TURNWATCH_INTERVAL_MS). */
|
|
350
351
|
turnWatchIntervalMs?: number
|
|
352
|
+
/** Profile-sync sweep cadence (default DEFAULT_PROFILE_SYNC_INTERVAL_MS). */
|
|
353
|
+
profileSyncIntervalMs?: number
|
|
351
354
|
/** Write the router.json discovery file (default true for production). */
|
|
352
355
|
discovery?: boolean
|
|
353
356
|
rootDir?: string
|
|
@@ -417,6 +420,34 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
417
420
|
} catch { /* best-effort */ }
|
|
418
421
|
},
|
|
419
422
|
})
|
|
423
|
+
// Profile-sync (identity self-heal, continuous half) — the local peer-profile.json is
|
|
424
|
+
// the SOURCE OF TRUTH and the registry only its projection; this sweep is what makes
|
|
425
|
+
// that invariant operationally true (an owner's hand-edit of the source propagates
|
|
426
|
+
// within a minute instead of waiting for someone to run a reindexing verb — the
|
|
427
|
+
// 17.07.2026 routing-desync incident class). mtime-gated: steady state is stats only.
|
|
428
|
+
const stopProfileSync = startProfileSync({
|
|
429
|
+
env,
|
|
430
|
+
intervalMs: opts.profileSyncIntervalMs ?? DEFAULT_PROFILE_SYNC_INTERVAL_MS,
|
|
431
|
+
onHealed: r => {
|
|
432
|
+
try {
|
|
433
|
+
appendLifecycleEvent(
|
|
434
|
+
cfg.eventLogDir,
|
|
435
|
+
{
|
|
436
|
+
ev: 'profile-reindex',
|
|
437
|
+
...(r.healed.length > 0 ? { healed: r.healed.join('; ').slice(0, 600) } : {}),
|
|
438
|
+
...(r.missing.length > 0 ? { missing: r.missing.join(', ').slice(0, 300) } : {}),
|
|
439
|
+
},
|
|
440
|
+
{ env },
|
|
441
|
+
)
|
|
442
|
+
} catch { /* best-effort */ }
|
|
443
|
+
},
|
|
444
|
+
onError: (err: unknown) => {
|
|
445
|
+
try {
|
|
446
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
447
|
+
appendLifecycleEvent(cfg.eventLogDir, { ev: 'profilesync-error', error: detail.replace(/\s+/g, ' ').slice(0, 600) }, { env })
|
|
448
|
+
} catch { /* best-effort */ }
|
|
449
|
+
},
|
|
450
|
+
})
|
|
420
451
|
const handle = await startDaemon({
|
|
421
452
|
wake: makeWakeFn(cfg, env),
|
|
422
453
|
// M2 arm-on-outbound (see makeArmEphemeralOnDelivered): ephemeral caller's ok
|
|
@@ -491,7 +522,7 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
491
522
|
// The mute-watch timer is OURS, not startDaemon's — so its teardown must hang off the
|
|
492
523
|
// handle we return, or a closed daemon would leave a live timer sweeping the disk (and
|
|
493
524
|
// every test that starts a daemon would hang on exit).
|
|
494
|
-
return { ...handle, close: async () => { stopMuteWatch(); stopGoalWatch(); stopTurnWatch(); await handle.close() } }
|
|
525
|
+
return { ...handle, close: async () => { stopMuteWatch(); stopGoalWatch(); stopTurnWatch(); stopProfileSync(); await handle.close() } }
|
|
495
526
|
}
|
|
496
527
|
|
|
497
528
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Profile-sync — the daemon's CONTINUOUS half of the registry self-heal invariant.
|
|
2
|
+
//
|
|
3
|
+
// THE INVARIANT (identity/profileStandard.ts; contract zone «Идентичность пира — модель,
|
|
4
|
+
// профиль, реестр»): the per-cwd peer-profile.json is the SOURCE OF TRUTH and
|
|
5
|
+
// peers-profiles.json is a regenerable PROJECTION of it — "the index is never the source
|
|
6
|
+
// of truth". Before this module that sentence was true only lexically. reindexFromLocals
|
|
7
|
+
// ran on a handful of verbs (add-runtime / default-runtime / connect / verify --fix), so
|
|
8
|
+
// an edit of the source file ITSELF — the owner's legitimate edit, on the very file the
|
|
9
|
+
// contract names as the truth — propagated NEVER unless one of those verbs happened to
|
|
10
|
+
// run afterwards. Measured on the real fleet (17.07.2026): linus's local profile had said
|
|
11
|
+
// default_runtime=claude for days; the index still said codex; the router, which reads
|
|
12
|
+
// the INDEX per-request, kept delivering into a mute codex session until an operator ran
|
|
13
|
+
// the verb by hand. A projection invariant without a continuous reconciler is a promise
|
|
14
|
+
// the system does not keep.
|
|
15
|
+
//
|
|
16
|
+
// MECHANISM. Every tick (60 s default) stat each registered peer's peer-profile.json.
|
|
17
|
+
// Only when a profile mtime advanced past the previous sweep's maximum — or the set of
|
|
18
|
+
// profile paths changed — run reindexFromLocals: the SAME locked writer the verbs use,
|
|
19
|
+
// so the registry single-writer invariant is untouched. The steady state is N stats and
|
|
20
|
+
// ZERO writes; the index is rewritten only when a source actually changed. Reindex
|
|
21
|
+
// writes the index and never the profiles, so there is no feedback loop. A corrupt or
|
|
22
|
+
// missing profile is preserved-and-reported by reindexFromLocals itself (it treats both
|
|
23
|
+
// as "leave the record, surface in `missing`" — a half-saved editor buffer must never
|
|
24
|
+
// wipe a live record).
|
|
25
|
+
//
|
|
26
|
+
// The boot tick ALWAYS reconciles once (the gate starts empty): drift accumulated while
|
|
27
|
+
// the daemon was down — exactly the incident shape — heals on startup, not on the next
|
|
28
|
+
// hand-run verb.
|
|
29
|
+
|
|
30
|
+
import { statSync } from 'node:fs'
|
|
31
|
+
import { reindexFromLocals } from '../identity/profileStandard.ts'
|
|
32
|
+
import { readPeersIndex } from '../registry/index.ts'
|
|
33
|
+
import { peerProfilePath } from '../storage/index.ts'
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_PROFILE_SYNC_INTERVAL_MS = 60_000
|
|
36
|
+
|
|
37
|
+
export interface ProfileSyncResult {
|
|
38
|
+
healed: string[]
|
|
39
|
+
missing: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ProfileSyncDeps {
|
|
43
|
+
/** Registered peers' profile paths. Default: the live registry (readPeersIndex). */
|
|
44
|
+
listProfilePaths?: () => string[]
|
|
45
|
+
/** The locked self-heal writer. Default: reindexFromLocals({ env }). */
|
|
46
|
+
reindex?: () => Promise<ProfileSyncResult>
|
|
47
|
+
/** Fired ONLY when a reindex actually changed/flagged something (healed or missing
|
|
48
|
+
* non-empty) — the caller's audit hook; quiet reconciles stay quiet. */
|
|
49
|
+
onHealed?: (r: ProfileSyncResult) => void
|
|
50
|
+
/** Reported, never thrown — a reconciler must never take the daemon down. */
|
|
51
|
+
onError?: (err: unknown) => void
|
|
52
|
+
env?: NodeJS.ProcessEnv
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The sweep gate: the profile set + the newest profile mtime, folded into one string.
|
|
56
|
+
* A changed gate = some source changed (edit, new peer, dropped profile) → reindex. */
|
|
57
|
+
function computeGate(paths: string[]): string {
|
|
58
|
+
let maxMtime = 0
|
|
59
|
+
const present: string[] = []
|
|
60
|
+
for (const p of paths) {
|
|
61
|
+
try {
|
|
62
|
+
const m = statSync(p).mtimeMs
|
|
63
|
+
present.push(p)
|
|
64
|
+
if (m > maxMtime) maxMtime = m
|
|
65
|
+
} catch {
|
|
66
|
+
// Missing profile: participates via the PATH SET (its appearance/disappearance
|
|
67
|
+
// flips the gate once), not via mtime. reindexFromLocals handles the rest.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return `${maxMtime}|${present.sort().join(',')}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One sweep: reindex IFF the gate moved since `lastGate`. Returns the new gate.
|
|
75
|
+
* Exported for tests; the timer below is just this on an interval.
|
|
76
|
+
*/
|
|
77
|
+
export async function profileSyncTick(deps: ProfileSyncDeps, lastGate: string): Promise<string> {
|
|
78
|
+
try {
|
|
79
|
+
const env = deps.env ?? process.env
|
|
80
|
+
const paths = deps.listProfilePaths
|
|
81
|
+
? deps.listProfilePaths()
|
|
82
|
+
: readPeersIndex({ env }).peers.map(rec => peerProfilePath(rec.cwd))
|
|
83
|
+
const gate = computeGate(paths)
|
|
84
|
+
if (gate === lastGate) return lastGate
|
|
85
|
+
const reindex = deps.reindex ?? (() => reindexFromLocals({ env }))
|
|
86
|
+
const result = await reindex()
|
|
87
|
+
if (result.healed.length > 0 || result.missing.length > 0) deps.onHealed?.(result)
|
|
88
|
+
return gate
|
|
89
|
+
} catch (e) {
|
|
90
|
+
deps.onError?.(e)
|
|
91
|
+
return lastGate // a failed sweep retries the same gate next tick — never silently skips
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Start the profile-sync timer. Returns its stop function — the caller OWNS teardown. */
|
|
96
|
+
export function startProfileSync(opts: ProfileSyncDeps & { intervalMs?: number } = {}): () => void {
|
|
97
|
+
let gate = '' // empty ≠ any computed gate → the FIRST tick always reconciles (boot heal)
|
|
98
|
+
let running = false
|
|
99
|
+
const sweep = async (): Promise<void> => {
|
|
100
|
+
if (running) return // a slow reindex must never overlap itself
|
|
101
|
+
running = true
|
|
102
|
+
try {
|
|
103
|
+
gate = await profileSyncTick(opts, gate)
|
|
104
|
+
} finally {
|
|
105
|
+
running = false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
void sweep() // boot heal — drift accumulated while the daemon was down heals NOW
|
|
109
|
+
const timer = setInterval(() => void sweep(), opts.intervalMs ?? DEFAULT_PROFILE_SYNC_INTERVAL_MS)
|
|
110
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
111
|
+
return () => clearInterval(timer)
|
|
112
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -162,12 +162,18 @@ export function resolvePeerDeliveryTarget(
|
|
|
162
162
|
peer: PeerRecord,
|
|
163
163
|
env: NodeJS.ProcessEnv = process.env,
|
|
164
164
|
): Result<DeliveryTarget> {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
165
|
+
// Routing is CONFIG-ANCHORED, not liveness-selected. An omitted runtime means
|
|
166
|
+
// exactly the peer's default_runtime (`PeerRecord.runtime` is the normalized
|
|
167
|
+
// in-memory name), even when another declared runtime already has a live
|
|
168
|
+
// session. If that default endpoint is asleep, returning its exact offline
|
|
169
|
+
// miss is load-bearing: routeSend will wake THE DEFAULT instead of falling
|
|
170
|
+
// through to whichever non-default session happens to be alive.
|
|
171
|
+
//
|
|
172
|
+
// An explicit runtime remains an exact override. `resolveDeliveryTarget`
|
|
173
|
+
// without a runtime is deliberately NOT used here: its sole-live discovery is
|
|
174
|
+
// useful for observation/control callers, but would silently defeat the
|
|
175
|
+
// operator's `default-runtime` routing lever.
|
|
176
|
+
return resolveDeliveryTarget({ personality, runtime: runtime ?? peer.runtime, env })
|
|
171
177
|
}
|
|
172
178
|
|
|
173
179
|
// Confirm poll interval — how often deliverViaHost re-reads the transcript for a NEW record carrying
|
|
@@ -1407,8 +1413,7 @@ export function hasTelegramPresence(record: PeerRecord): boolean {
|
|
|
1407
1413
|
* ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
|
|
1408
1414
|
* the INTENDED channel (override / target default — before any wake side-effect,
|
|
1409
1415
|
* since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
|
|
1410
|
-
* channel at both delivery points (
|
|
1411
|
-
* telegram without either). */
|
|
1416
|
+
* channel at both delivery points (defence in depth against registry/wake drift). */
|
|
1412
1417
|
function telegramSenderGuard(
|
|
1413
1418
|
caller: ResolvedCaller,
|
|
1414
1419
|
targetRuntime: string,
|
|
@@ -1500,9 +1505,8 @@ async function routeSendInner(
|
|
|
1500
1505
|
// a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
|
|
1501
1506
|
// agent then confirms cross-channel or redirects to the origin, zero regeneration.
|
|
1502
1507
|
// Intended runtime = the explicit override or the human's default; exact for the
|
|
1503
|
-
// natural-peer class (
|
|
1504
|
-
//
|
|
1505
|
-
// window). Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
|
|
1508
|
+
// natural-peer class (implicit routing is strictly default_runtime-anchored).
|
|
1509
|
+
// Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
|
|
1506
1510
|
if (
|
|
1507
1511
|
!deps.originGuardBypass &&
|
|
1508
1512
|
originGuardEnabled(env) &&
|
|
@@ -1561,8 +1565,8 @@ async function routeSendInner(
|
|
|
1561
1565
|
const target = resolvePeerDeliveryTarget(personality, runtime, peer, env)
|
|
1562
1566
|
if (target.ok) {
|
|
1563
1567
|
if (target.value.address === caller.address) return err('cannot send to self')
|
|
1564
|
-
// Telegram sender policy — RESOLVED channel (
|
|
1565
|
-
//
|
|
1568
|
+
// Telegram sender policy — RESOLVED channel (defence in depth: the resolved
|
|
1569
|
+
// target must still obey the domain guard even though selection is exact).
|
|
1566
1570
|
const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
|
|
1567
1571
|
if (!liveGuard.ok) return liveGuard
|
|
1568
1572
|
const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })
|