@agfpd/iapeer 0.4.103 → 0.4.104

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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.103",
3
+ "version": "0.4.104",
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",
@@ -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
+ }