@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.
- package/docs/08-peer-management.md +2 -0
- package/package.json +1 -1
- package/src/daemon/main.ts +32 -1
- package/src/daemon/profilesync.ts +112 -0
|
@@ -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
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
|
+
}
|