@adhdev/daemon-core 0.9.82-rc.460 → 0.9.82-rc.461
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/dist/config/mesh-config.d.ts +7 -0
- package/dist/detection/cli-detector.d.ts +17 -0
- package/dist/git/git-commands.d.ts +14 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +461 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +459 -21
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +43 -1
- package/dist/mesh/mesh-events.d.ts +1 -1
- package/dist/mesh/mesh-node-identity.d.ts +4 -0
- package/dist/mesh/mesh-runtime-store.d.ts +17 -0
- package/dist/repo-mesh-types.d.ts +80 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +15 -2
- package/src/commands/high-family/mesh-status.ts +10 -0
- package/src/config/mesh-config.ts +13 -0
- package/src/detection/cli-detector.ts +66 -0
- package/src/git/git-commands.ts +35 -2
- package/src/index.ts +1 -1
- package/src/mesh/coordinator-prompt.ts +19 -1
- package/src/mesh/mesh-event-forwarding.ts +19 -1
- package/src/mesh/mesh-events-pending.ts +371 -5
- package/src/mesh/mesh-events.ts +2 -0
- package/src/mesh/mesh-node-identity.ts +77 -6
- package/src/mesh/mesh-reconcile-loop.ts +8 -1
- package/src/mesh/mesh-runtime-store.ts +30 -0
- package/src/repo-mesh-types.ts +79 -0
|
@@ -5,13 +5,19 @@ import { LOG } from '../logging/logger.js';
|
|
|
5
5
|
import { getLedgerDir, readLedgerEntries, appendLedgerEntry } from './mesh-ledger.js';
|
|
6
6
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
7
7
|
import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId, readMeshCompletionSummary, isWeakCompletionMetadata } from './mesh-events-utils.js';
|
|
8
|
-
import { expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
8
|
+
import { daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
|
|
9
9
|
import {
|
|
10
|
+
assertPendingMeshCoordinatorEventV2,
|
|
10
11
|
buildPendingEventEmitStamp,
|
|
12
|
+
coordinatorIdentityEquals,
|
|
11
13
|
coordinatorIdentityFromEmitFields,
|
|
14
|
+
coordinatorIdentityKey,
|
|
15
|
+
isMeshEventScope,
|
|
12
16
|
MESH_PROTOCOL_VERSION_V2,
|
|
17
|
+
shouldDeliverPendingEventToCoordinator,
|
|
13
18
|
type CoordinatorIdentity,
|
|
14
19
|
type MeshEventScope,
|
|
20
|
+
type PendingMeshCoordinatorEventV2,
|
|
15
21
|
} from './contracts.js';
|
|
16
22
|
|
|
17
23
|
// ---------------------------------------------------------------------------
|
|
@@ -108,6 +114,257 @@ function normalizeCoordinatorDaemonIds(
|
|
|
108
114
|
return expandDaemonIdForms(coordinatorDaemonId);
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
// ─── B3a: drain-side v2 routing (accept-and-warn) ────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// Stage-1 of the v2 receive path. The drain scope is still primarily gated by the
|
|
120
|
+
// SQLite/JSONL `coordinator_daemon_id` filter (v1 mechanism, untouched here), so
|
|
121
|
+
// this layer runs on top of an already daemon-scoped candidate set and adds:
|
|
122
|
+
//
|
|
123
|
+
// 1. v2 unicast routing — an event whose intendedFor addresses a DIFFERENT
|
|
124
|
+
// coordinator on THIS daemon (a sibling CLI/MCP session) is skipped, so a
|
|
125
|
+
// completion doesn't cross-surface. Broadcast/system-as-broadcast pass.
|
|
126
|
+
// 2. eventId idempotency — a v2 event whose eventId was already drained is
|
|
127
|
+
// skipped (durable, via MeshRuntimeStore.hasDrainedEventId) plus a per-drain
|
|
128
|
+
// batch guard against same-batch duplicates.
|
|
129
|
+
// 3. accept-and-warn — a v2 event that FAILS validation, or has NO version, is
|
|
130
|
+
// NOT dropped: it passes through (no v1 regression) with a one-shot WARN and
|
|
131
|
+
// a counter bump. Hard rejection is T6 (enforce mode), not here.
|
|
132
|
+
// 4. re-attribution fallback — a unicast event whose intendedFor does not match
|
|
133
|
+
// the drainer by identity is NOT dropped when its intendedFor.daemonId is the
|
|
134
|
+
// SAME machine as the drainer (a coordinatorRunId change from a restart
|
|
135
|
+
// orphaned it): it is delivered to the current coordinator on that daemon.
|
|
136
|
+
|
|
137
|
+
/** Observability counters for the accept-and-warn rollout. Read by tests and (later,
|
|
138
|
+
* B4) surfaced in mesh_status. Process-lifetime totals — never reset in production. */
|
|
139
|
+
const meshV2DrainCounters = {
|
|
140
|
+
/** v2 events that passed validation and unicast/broadcast routing → delivered. */
|
|
141
|
+
v2Delivered: 0,
|
|
142
|
+
/** v2 unicast events skipped because intendedFor addressed another coordinator. */
|
|
143
|
+
v2RoutedAway: 0,
|
|
144
|
+
/** v2 events skipped because their eventId was already drained (idempotency). */
|
|
145
|
+
v2DedupSkipped: 0,
|
|
146
|
+
/** v2 events that failed assertPendingMeshCoordinatorEventV2 but were PASSED
|
|
147
|
+
* THROUGH (accept mode). Non-zero here is the rollout signal that a producer
|
|
148
|
+
* emits a malformed envelope. */
|
|
149
|
+
v2ValidationFailedAccepted: 0,
|
|
150
|
+
/** unicast events re-attributed to the drainer via daemon-core match (a
|
|
151
|
+
* coordinatorRunId change orphaned them). */
|
|
152
|
+
v2ReattributedToDrainer: 0,
|
|
153
|
+
/** v1 (unversioned) events passed through as broadcast (rollout baseline). */
|
|
154
|
+
v1BroadcastAccepted: 0,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Test/observability accessor for the v2 drain counters (snapshot copy). */
|
|
158
|
+
export function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters> {
|
|
159
|
+
return { ...meshV2DrainCounters };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Test helper: zero the v2 drain counters so a test starts from a clean slate. */
|
|
163
|
+
export function __resetMeshV2DrainCountersForTests(): void {
|
|
164
|
+
for (const k of Object.keys(meshV2DrainCounters) as Array<keyof typeof meshV2DrainCounters>) {
|
|
165
|
+
meshV2DrainCounters[k] = 0;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// One-shot WARN dedup: an accept-mode warning is logged once per (meshId, eventId)
|
|
170
|
+
// so a re-polled malformed event doesn't spam the log every 4s reconcile tick.
|
|
171
|
+
const warnedV2Violations = new Set<string>();
|
|
172
|
+
function warnV2Once(key: string, message: string): void {
|
|
173
|
+
if (warnedV2Violations.has(key)) return;
|
|
174
|
+
warnedV2Violations.add(key);
|
|
175
|
+
// Bound the set so a long-lived daemon churning many distinct eventIds can't leak.
|
|
176
|
+
if (warnedV2Violations.size > 2000) {
|
|
177
|
+
const first = warnedV2Violations.values().next().value;
|
|
178
|
+
if (first !== undefined) warnedV2Violations.delete(first);
|
|
179
|
+
}
|
|
180
|
+
LOG.warn('MeshEventsV2', message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Test helper: clear the one-shot WARN dedup set. */
|
|
184
|
+
export function __resetMeshV2WarnDedupForTests(): void {
|
|
185
|
+
warnedV2Violations.clear();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resolve the drainer's CoordinatorIdentity for v2 routing from the daemon-id
|
|
190
|
+
* argument the (untouchable) reconcile-loop already passes. The daemon ids are the
|
|
191
|
+
* dual/expanded self-identity forms from resolveCoordinatorDaemonIds; the FIRST is
|
|
192
|
+
* the primary. coordinatorRunId is not threaded through the drain call yet, so it
|
|
193
|
+
* falls back to the daemonId exactly as the emit side does
|
|
194
|
+
* (coordinatorIdentityFromEmitFields) — this keeps drain-side identity CONSISTENT
|
|
195
|
+
* with how v1→v2 events were stamped, and unicast equality then reduces to the
|
|
196
|
+
* daemon-core match, which is the correct rollout-window granularity. A caller that
|
|
197
|
+
* knows the full identity (with a real coordinatorRunId) may pass it explicitly to
|
|
198
|
+
* override. Returns undefined when no daemon id is known (→ v2 routing is a no-op,
|
|
199
|
+
* everything passes as-is).
|
|
200
|
+
*/
|
|
201
|
+
function resolveDrainerIdentity(
|
|
202
|
+
daemonIds: ReadonlyArray<string>,
|
|
203
|
+
explicit?: CoordinatorIdentity,
|
|
204
|
+
): CoordinatorIdentity | undefined {
|
|
205
|
+
if (explicit) return explicit;
|
|
206
|
+
return coordinatorIdentityFromEmitFields({ daemonId: daemonIds[0] });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** A v2 event carries a '2.0' protocolVersion. Everything else is a v1 event. */
|
|
210
|
+
function isV2Event(event: PendingMeshCoordinatorEvent): boolean {
|
|
211
|
+
return event.protocolVersion === MESH_PROTOCOL_VERSION_V2;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* True when an identity's coordinatorRunId is merely its own daemonId form (the
|
|
216
|
+
* B2a fallback in coordinatorIdentityFromEmitFields — no real coordinatorRunId was
|
|
217
|
+
* threaded through the emit/drain site yet). For such an identity the runId carries
|
|
218
|
+
* NO information beyond the daemon, so two different daemon-id FORMS of the same
|
|
219
|
+
* machine (mach_ vs daemon_mach_) must not be treated as different coordinators.
|
|
220
|
+
*/
|
|
221
|
+
function runIdIsDaemonFormFallback(identity: CoordinatorIdentity): boolean {
|
|
222
|
+
return daemonIdsEquivalent(identity.coordinatorRunId, identity.daemonId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Delivery equality for the rollout window. When BOTH sides carry only a
|
|
227
|
+
* daemon-form-fallback runId (no real coordinatorRunId wired yet), a match reduces
|
|
228
|
+
* to same-machine — so a completion stamped `daemon_mach_X` is delivered to a
|
|
229
|
+
* coordinator that knows itself as bare `mach_X` (the canon-identity heterogeneous-
|
|
230
|
+
* form case). The session is compared ONLY when BOTH sides carry one: a session-less
|
|
231
|
+
* drainer is a daemon-level drain (what the reconcile loop passes) that accepts any
|
|
232
|
+
* session's events on that machine — targetCoordinatorSessionId is a PHASE-2 inject
|
|
233
|
+
* key, not a drain-scoping key (see the v1 field comment). When only the drainer AND
|
|
234
|
+
* the event both name a session do we require them to match, so a session-specific
|
|
235
|
+
* coordinator does not receive a sibling session's unicast event.
|
|
236
|
+
*
|
|
237
|
+
* When EITHER side has a real (non-daemon-form) runId, fall back to strict
|
|
238
|
+
* coordinatorIdentityEquals so two genuinely distinct coordinators on the same
|
|
239
|
+
* daemon (different real runIds) stay separated.
|
|
240
|
+
*/
|
|
241
|
+
function identityDeliversTo(intendedFor: CoordinatorIdentity, drainer: CoordinatorIdentity): boolean {
|
|
242
|
+
if (runIdIsDaemonFormFallback(intendedFor) && runIdIsDaemonFormFallback(drainer)) {
|
|
243
|
+
if (!daemonIdsEquivalent(intendedFor.daemonId, drainer.daemonId)) return false;
|
|
244
|
+
// Session filter applies only when the drainer itself is session-specific.
|
|
245
|
+
if (intendedFor.sessionId && drainer.sessionId) {
|
|
246
|
+
return intendedFor.sessionId === drainer.sessionId;
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return coordinatorIdentityEquals(intendedFor, drainer);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Apply v2 receive-side routing + idempotency to a merged, already daemon-scoped
|
|
255
|
+
* candidate list (accept-and-warn — never drops on validation failure).
|
|
256
|
+
*
|
|
257
|
+
* - `drainer` undefined → routing is skipped, list returned unchanged (safety).
|
|
258
|
+
* - Marks each surviving v2 event's eventId in `batchSeen` so a same-batch dup is
|
|
259
|
+
* skipped; a caller that persists drains uses `alreadyDrained` for the durable
|
|
260
|
+
* check (getPending peek passes a no-op so a peek never dedups against itself).
|
|
261
|
+
*/
|
|
262
|
+
function routeV2EventsForDrainer(
|
|
263
|
+
events: PendingMeshCoordinatorEvent[],
|
|
264
|
+
drainer: CoordinatorIdentity | undefined,
|
|
265
|
+
ctx: {
|
|
266
|
+
alreadyDrained: (eventId: string) => boolean;
|
|
267
|
+
batchSeen: Set<string>;
|
|
268
|
+
/** false for a non-destructive peek so the frequent status-poll path does
|
|
269
|
+
* not inflate the delivery counters (only the real drain counts). */
|
|
270
|
+
countMetrics: boolean;
|
|
271
|
+
},
|
|
272
|
+
): PendingMeshCoordinatorEvent[] {
|
|
273
|
+
if (!drainer) return events;
|
|
274
|
+
const bump = (k: keyof typeof meshV2DrainCounters) => { if (ctx.countMetrics) meshV2DrainCounters[k]++; };
|
|
275
|
+
const kept: PendingMeshCoordinatorEvent[] = [];
|
|
276
|
+
for (const event of events) {
|
|
277
|
+
if (!isV2Event(event)) {
|
|
278
|
+
// v1 / unversioned event → broadcast during rollout (existing policy).
|
|
279
|
+
bump('v1BroadcastAccepted');
|
|
280
|
+
kept.push(event);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Validate the v2 envelope. ACCEPT MODE: a validation failure does NOT drop
|
|
285
|
+
// the event — it passes through with a one-shot WARN + counter. (T6 enforce
|
|
286
|
+
// mode is where this becomes a quarantine.)
|
|
287
|
+
let validated: PendingMeshCoordinatorEventV2;
|
|
288
|
+
try {
|
|
289
|
+
validated = assertPendingMeshCoordinatorEventV2(event);
|
|
290
|
+
} catch (e: any) {
|
|
291
|
+
bump('v2ValidationFailedAccepted');
|
|
292
|
+
warnV2Once(
|
|
293
|
+
`${event.meshId}::${event.eventId ?? event.event}::invalid`,
|
|
294
|
+
`v2 envelope validation failed for ${event.event} on mesh ${event.meshId} — PASSED THROUGH (accept mode): ${e?.message || e}`,
|
|
295
|
+
);
|
|
296
|
+
kept.push(event);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// eventId idempotency: skip if already drained (durable) or already seen in
|
|
301
|
+
// this same batch (guards the SQLite+JSONL dual-store merge duplicate).
|
|
302
|
+
const eventId = validated.eventId;
|
|
303
|
+
if (ctx.batchSeen.has(eventId) || ctx.alreadyDrained(eventId)) {
|
|
304
|
+
bump('v2DedupSkipped');
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Broadcast → any coordinator; system → daemon handler only (never a
|
|
309
|
+
// coordinator). Delegates to the contract helper for those two scopes.
|
|
310
|
+
if (validated.scope !== 'unicast') {
|
|
311
|
+
if (shouldDeliverPendingEventToCoordinator(validated, drainer)) {
|
|
312
|
+
ctx.batchSeen.add(eventId);
|
|
313
|
+
bump('v2Delivered');
|
|
314
|
+
kept.push(event);
|
|
315
|
+
} else {
|
|
316
|
+
// system scope → not for any coordinator.
|
|
317
|
+
bump('v2RoutedAway');
|
|
318
|
+
}
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Unicast: deliver iff intendedFor addresses THIS drainer. identityDeliversTo
|
|
323
|
+
// treats a daemon-form-fallback runId (no real coordinatorRunId wired yet) as
|
|
324
|
+
// form-agnostic so a `daemon_mach_X`-addressed event reaches a bare-`mach_X`
|
|
325
|
+
// drainer (heterogeneous-form same coordinator), while keeping two REAL
|
|
326
|
+
// distinct runIds on one daemon separated.
|
|
327
|
+
if (validated.intendedFor && identityDeliversTo(validated.intendedFor, drainer)) {
|
|
328
|
+
ctx.batchSeen.add(eventId);
|
|
329
|
+
bump('v2Delivered');
|
|
330
|
+
kept.push(event);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Not delivered by identity. Apply the re-attribution fallback (plan risk
|
|
335
|
+
// §4): if intendedFor addresses the SAME MACHINE as the drainer AND the
|
|
336
|
+
// mismatch is a genuine coordinatorRunId change (a restart minted a fresh
|
|
337
|
+
// runId), deliver it to the current coordinator rather than orphaning it.
|
|
338
|
+
//
|
|
339
|
+
// Guard: when BOTH sides carry only a daemon-form-fallback runId, a mismatch
|
|
340
|
+
// that survived identityDeliversTo is a SESSION mismatch (a sibling
|
|
341
|
+
// coordinator on the same daemon) — that is a legitimate route-away, NOT an
|
|
342
|
+
// orphaned event, so re-attribution must not fire. Re-attribution requires a
|
|
343
|
+
// REAL runId difference, which means at least one side carries a real runId.
|
|
344
|
+
const realRunIdMismatch = !runIdIsDaemonFormFallback(validated.intendedFor!)
|
|
345
|
+
|| !runIdIsDaemonFormFallback(drainer);
|
|
346
|
+
if (
|
|
347
|
+
validated.intendedFor
|
|
348
|
+
&& realRunIdMismatch
|
|
349
|
+
&& daemonIdsEquivalent(validated.intendedFor.daemonId, drainer.daemonId)
|
|
350
|
+
) {
|
|
351
|
+
ctx.batchSeen.add(eventId);
|
|
352
|
+
bump('v2ReattributedToDrainer');
|
|
353
|
+
warnV2Once(
|
|
354
|
+
`${event.meshId}::${eventId}::reattributed`,
|
|
355
|
+
`v2 unicast ${event.event} on mesh ${event.meshId} re-attributed to current coordinator ${coordinatorIdentityKey(drainer)} (originating coordinatorRunId no longer live)`,
|
|
356
|
+
);
|
|
357
|
+
kept.push(event);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Addressed to a genuinely different coordinator (different machine, or
|
|
362
|
+
// system scope) → not for this drainer. Skipped (left for its own drainer).
|
|
363
|
+
bump('v2RoutedAway');
|
|
364
|
+
}
|
|
365
|
+
return kept;
|
|
366
|
+
}
|
|
367
|
+
|
|
111
368
|
export function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
|
|
112
369
|
const metadata = readRecord((event as any).metadataEvent) || event as Record<string, unknown>;
|
|
113
370
|
const result = readRecord(metadata.result);
|
|
@@ -422,6 +679,74 @@ export function stampPendingEventV2(
|
|
|
422
679
|
};
|
|
423
680
|
}
|
|
424
681
|
|
|
682
|
+
// ─── v2 envelope: remote (P2P) boundary preservation (B3b/T4) ─────────────
|
|
683
|
+
//
|
|
684
|
+
// The remote pull round-trip (mesh-reconcile-loop pullRemoteNodeQueues →
|
|
685
|
+
// get_pending_mesh_events → buildForwardPayloadFromPending → handleMeshForwardEvent
|
|
686
|
+
// → queuePendingMeshCoordinatorEvent) flattens a queued PendingMeshCoordinatorEvent
|
|
687
|
+
// into a flat wire payload and rebuilds it on the receiving daemon. The v2 envelope
|
|
688
|
+
// fields (protocolVersion / eventId / scope / dispatchedBy / intendedFor) live at the
|
|
689
|
+
// TOP LEVEL of the event, not inside metadataEvent, so the flatten/rebuild whitelist
|
|
690
|
+
// dropped them: the re-queue then re-stamped a FRESH eventId, breaking cross-machine
|
|
691
|
+
// idempotency and downgrading the relayed completion to v1 (broadcast) routing.
|
|
692
|
+
//
|
|
693
|
+
// These two helpers are the single serialization/deserialization pair for that
|
|
694
|
+
// boundary. serializeV2EnvelopeToWire copies the present v2 fields onto the flat
|
|
695
|
+
// payload; readV2EnvelopeFromWire validates and restores them for the re-queue. The
|
|
696
|
+
// eventId is carried verbatim so stampPendingEventV2's already-stamped short-circuit
|
|
697
|
+
// preserves it (no new UUID). Kept pure + exported so the round-trip is unit-testable.
|
|
698
|
+
|
|
699
|
+
/** Read a CoordinatorIdentity off an untrusted wire object, or undefined if malformed. */
|
|
700
|
+
function readCoordinatorIdentityFromWire(raw: unknown): CoordinatorIdentity | undefined {
|
|
701
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
702
|
+
const obj = raw as Record<string, unknown>;
|
|
703
|
+
const daemonId = readNonEmptyString(obj.daemonId);
|
|
704
|
+
const coordinatorRunId = readNonEmptyString(obj.coordinatorRunId);
|
|
705
|
+
if (!daemonId || !coordinatorRunId) return undefined;
|
|
706
|
+
const sessionId = readNonEmptyString(obj.sessionId);
|
|
707
|
+
return { daemonId, coordinatorRunId, ...(sessionId ? { sessionId } : {}) };
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Copy the v2 envelope fields that are present on `event` onto a flat wire
|
|
712
|
+
* payload. Only sets a field when it is present, so a v1 event contributes
|
|
713
|
+
* nothing (the payload stays v1-shaped and version-skew safe).
|
|
714
|
+
*/
|
|
715
|
+
export function serializeV2EnvelopeToWire(event: PendingMeshCoordinatorEvent): Record<string, unknown> {
|
|
716
|
+
const out: Record<string, unknown> = {};
|
|
717
|
+
if (event.protocolVersion) out.protocolVersion = event.protocolVersion;
|
|
718
|
+
if (readNonEmptyString(event.eventId)) out.eventId = event.eventId;
|
|
719
|
+
if (event.scope) out.scope = event.scope;
|
|
720
|
+
if (event.dispatchedBy) out.dispatchedBy = event.dispatchedBy;
|
|
721
|
+
if (event.intendedFor) out.intendedFor = event.intendedFor;
|
|
722
|
+
return out;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Restore the v2 envelope fields from a flat wire payload for a re-queue. Only
|
|
727
|
+
* returns fields that survive validation; a payload missing/malforming a field
|
|
728
|
+
* yields a partial (or empty) object so the re-queue path stays v1-safe. The
|
|
729
|
+
* eventId is returned verbatim — its preservation is the idempotency guarantee.
|
|
730
|
+
*/
|
|
731
|
+
export function readV2EnvelopeFromWire(payload: Record<string, unknown>): Partial<Pick<
|
|
732
|
+
PendingMeshCoordinatorEvent,
|
|
733
|
+
'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
|
|
734
|
+
>> {
|
|
735
|
+
const out: Partial<Pick<
|
|
736
|
+
PendingMeshCoordinatorEvent,
|
|
737
|
+
'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
|
|
738
|
+
>> = {};
|
|
739
|
+
if (payload.protocolVersion === MESH_PROTOCOL_VERSION_V2) out.protocolVersion = MESH_PROTOCOL_VERSION_V2;
|
|
740
|
+
const eventId = readNonEmptyString(payload.eventId);
|
|
741
|
+
if (eventId) out.eventId = eventId;
|
|
742
|
+
if (isMeshEventScope(payload.scope)) out.scope = payload.scope;
|
|
743
|
+
const dispatchedBy = readCoordinatorIdentityFromWire(payload.dispatchedBy);
|
|
744
|
+
if (dispatchedBy) out.dispatchedBy = dispatchedBy;
|
|
745
|
+
const intendedFor = readCoordinatorIdentityFromWire(payload.intendedFor);
|
|
746
|
+
if (intendedFor) out.intendedFor = intendedFor;
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
425
750
|
export function queuePendingMeshCoordinatorEvent(
|
|
426
751
|
rawEvent: PendingMeshCoordinatorEvent,
|
|
427
752
|
hint?: PendingEventEmitHint,
|
|
@@ -569,7 +894,7 @@ function selectiveDrainFile(
|
|
|
569
894
|
export function drainPendingMeshCoordinatorEvents(
|
|
570
895
|
meshId?: string,
|
|
571
896
|
coordinatorDaemonId?: string | ReadonlyArray<string>,
|
|
572
|
-
opts?: { onlyEvents?: ReadonlySet<string
|
|
897
|
+
opts?: { onlyEvents?: ReadonlySet<string>; drainerIdentity?: CoordinatorIdentity },
|
|
573
898
|
): PendingMeshCoordinatorEvent[] {
|
|
574
899
|
if (!meshId) return [];
|
|
575
900
|
|
|
@@ -580,6 +905,18 @@ export function drainPendingMeshCoordinatorEvents(
|
|
|
580
905
|
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
581
906
|
const primaryDaemonId = daemonIds[0];
|
|
582
907
|
|
|
908
|
+
// B3a: the drainer's v2 identity, for unicast routing + eventId dedup. Derived
|
|
909
|
+
// from the daemon ids the (untouchable) reconcile-loop already passes, so no
|
|
910
|
+
// caller change is required; a caller may still pass the full identity.
|
|
911
|
+
const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
|
|
912
|
+
// Snapshot the ALREADY-drained v2 eventIds BEFORE the SQLite drain marks this
|
|
913
|
+
// batch drained=1 — the re-delivery dedup baseline. Reading it after would
|
|
914
|
+
// self-match the batch's own rows.
|
|
915
|
+
let priorDrainedEventIds = new Set<string>();
|
|
916
|
+
try {
|
|
917
|
+
priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
|
|
918
|
+
} catch { /* store unavailable — no durable baseline; batch guard still applies */ }
|
|
919
|
+
|
|
583
920
|
const onlyEvents = opts?.onlyEvents;
|
|
584
921
|
const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
|
|
585
922
|
|
|
@@ -648,7 +985,17 @@ export function drainPendingMeshCoordinatorEvents(
|
|
|
648
985
|
// longer exists — delivery is now queue-drain-only (reconcile loop or MCP pull),
|
|
649
986
|
// so an event is consumed by exactly one drainer via the atomic SQLite drained=1
|
|
650
987
|
// marking. There is no PTY-vs-poll double path left to dedup against.
|
|
651
|
-
|
|
988
|
+
//
|
|
989
|
+
// B3a: v2 receive-side routing (accept-and-warn) — unicast targeting, eventId
|
|
990
|
+
// idempotency, malformed-envelope pass-through-with-warn. v1 events broadcast.
|
|
991
|
+
// Runs AFTER the merge/reconcile so a single eventId dedup batch covers both
|
|
992
|
+
// stores. Non-destructive to v1 behaviour when no drainer identity is known.
|
|
993
|
+
const routed = routeV2EventsForDrainer(merged, drainer, {
|
|
994
|
+
alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
|
|
995
|
+
batchSeen: new Set<string>(),
|
|
996
|
+
countMetrics: true,
|
|
997
|
+
});
|
|
998
|
+
return reconcilePendingMeshCoordinatorEvents(meshId, routed);
|
|
652
999
|
}
|
|
653
1000
|
|
|
654
1001
|
/**
|
|
@@ -703,9 +1050,21 @@ export function retractPendingDispatchBlockedEvent(
|
|
|
703
1050
|
}
|
|
704
1051
|
|
|
705
1052
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
706
|
-
export function getPendingMeshCoordinatorEvents(
|
|
1053
|
+
export function getPendingMeshCoordinatorEvents(
|
|
1054
|
+
meshId?: string,
|
|
1055
|
+
coordinatorDaemonId?: string | ReadonlyArray<string>,
|
|
1056
|
+
opts?: { drainerIdentity?: CoordinatorIdentity },
|
|
1057
|
+
): readonly PendingMeshCoordinatorEvent[] {
|
|
707
1058
|
if (!meshId) return [];
|
|
708
1059
|
const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
|
|
1060
|
+
// B3a: same v2 routing the destructive drain applies, so a peek (mesh_status
|
|
1061
|
+
// count, reconcile pre-check) sees the SAME set the drain would deliver — a
|
|
1062
|
+
// unicast event for another coordinator is not counted for this one.
|
|
1063
|
+
const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
|
|
1064
|
+
let priorDrainedEventIds = new Set<string>();
|
|
1065
|
+
try {
|
|
1066
|
+
priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
|
|
1067
|
+
} catch { /* store unavailable — batch guard still applies */ }
|
|
709
1068
|
|
|
710
1069
|
// Merge SQLite (primary) + JSONL (legacy) with fingerprint dedup.
|
|
711
1070
|
const merged: PendingMeshCoordinatorEvent[] = [];
|
|
@@ -737,7 +1096,14 @@ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaem
|
|
|
737
1096
|
|
|
738
1097
|
// (Former R3 direct-delivered filter removed — no PTY direct-inject path exists
|
|
739
1098
|
// anymore, so a peeked pending event has genuinely not yet been consumed.)
|
|
740
|
-
|
|
1099
|
+
// B3a: apply the SAME v2 routing the drain applies (non-destructive: no counter
|
|
1100
|
+
// inflation on the frequent status-poll path).
|
|
1101
|
+
const routed = routeV2EventsForDrainer(merged, drainer, {
|
|
1102
|
+
alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
|
|
1103
|
+
batchSeen: new Set<string>(),
|
|
1104
|
+
countMetrics: false,
|
|
1105
|
+
});
|
|
1106
|
+
return reconcilePendingMeshCoordinatorEvents(meshId, routed);
|
|
741
1107
|
}
|
|
742
1108
|
|
|
743
1109
|
/**
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -319,9 +319,21 @@ export function recordInlineMeshDirectGitTruth(
|
|
|
319
319
|
node: any,
|
|
320
320
|
git: Record<string, unknown>,
|
|
321
321
|
source: 'selected_coordinator_local_git' | 'selected_coordinator_mesh_p2p_git',
|
|
322
|
-
): {
|
|
322
|
+
): {
|
|
323
|
+
reporterPlatform: string | null;
|
|
324
|
+
reporterArch: string | null;
|
|
325
|
+
reporterMachineNickname: string | null;
|
|
326
|
+
reporterProviderVersions: Record<string, string> | null;
|
|
327
|
+
reporterDaemonBuildVersion: string | null;
|
|
328
|
+
} {
|
|
323
329
|
if (!node || typeof node !== 'object' || Array.isArray(node)) {
|
|
324
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
reporterPlatform: null,
|
|
332
|
+
reporterArch: null,
|
|
333
|
+
reporterMachineNickname: null,
|
|
334
|
+
reporterProviderVersions: null,
|
|
335
|
+
reporterDaemonBuildVersion: null,
|
|
336
|
+
};
|
|
325
337
|
}
|
|
326
338
|
const checkedAt = readNumberValue(git.lastCheckedAt) ?? Date.now();
|
|
327
339
|
const updatedAt = new Date(checkedAt).toISOString();
|
|
@@ -366,7 +378,38 @@ export function recordInlineMeshDirectGitTruth(
|
|
|
366
378
|
// overwrite an existing nickname with an empty value.
|
|
367
379
|
const reporterMachineNickname = readStringValue(git.reporterMachineNickname) ?? null;
|
|
368
380
|
if (reporterMachineNickname) node.machineNickname = reporterMachineNickname;
|
|
369
|
-
|
|
381
|
+
// T7: self-heal provider versions + daemon build version from the same git_status
|
|
382
|
+
// envelope. These are best-effort observability (never routing), so the raw
|
|
383
|
+
// reported map is stamped onto dedicated node fields and overwritten by the next
|
|
384
|
+
// report — never merged with a stale value the way an operator override would be.
|
|
385
|
+
const reporterProviderVersions = readProviderVersionsRecord(git.reporterProviderVersions);
|
|
386
|
+
if (reporterProviderVersions) node.reportedProviderVersions = reporterProviderVersions;
|
|
387
|
+
const reporterDaemonBuildVersion = readStringValue(git.reporterDaemonBuildVersion) ?? null;
|
|
388
|
+
if (reporterDaemonBuildVersion) node.reportedDaemonBuildVersion = reporterDaemonBuildVersion;
|
|
389
|
+
return {
|
|
390
|
+
reporterPlatform,
|
|
391
|
+
reporterArch,
|
|
392
|
+
reporterMachineNickname,
|
|
393
|
+
reporterProviderVersions,
|
|
394
|
+
reporterDaemonBuildVersion,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Coerce an unknown git-envelope `reporterProviderVersions` field into a clean
|
|
400
|
+
* `{ providerId: version }` record: only string→non-empty-string entries survive.
|
|
401
|
+
* Returns null when nothing usable is present so callers can skip the stamp.
|
|
402
|
+
*/
|
|
403
|
+
function readProviderVersionsRecord(value: unknown): Record<string, string> | null {
|
|
404
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
405
|
+
const out: Record<string, string> = {};
|
|
406
|
+
for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
|
|
407
|
+
if (typeof key !== 'string' || !key.trim()) continue;
|
|
408
|
+
const version = typeof raw === 'string' ? raw.trim() : '';
|
|
409
|
+
if (!version) continue;
|
|
410
|
+
out[key] = version;
|
|
411
|
+
}
|
|
412
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
370
413
|
}
|
|
371
414
|
|
|
372
415
|
/**
|
|
@@ -403,7 +446,13 @@ export function persistNodeReporterPlatform(
|
|
|
403
446
|
meshSource: 'inline_cache' | 'inline_bootstrap' | 'local_config',
|
|
404
447
|
mesh: any,
|
|
405
448
|
nodeId: string | undefined,
|
|
406
|
-
reporter: {
|
|
449
|
+
reporter: {
|
|
450
|
+
reporterPlatform: string | null;
|
|
451
|
+
reporterArch: string | null;
|
|
452
|
+
reporterMachineNickname?: string | null;
|
|
453
|
+
reporterProviderVersions?: Record<string, string> | null;
|
|
454
|
+
reporterDaemonBuildVersion?: string | null;
|
|
455
|
+
},
|
|
407
456
|
): void {
|
|
408
457
|
if (meshSource !== 'local_config') return;
|
|
409
458
|
const meshId = readStringValue(mesh?.id);
|
|
@@ -411,9 +460,25 @@ export function persistNodeReporterPlatform(
|
|
|
411
460
|
const reportedPlatform = reporter.reporterPlatform ?? undefined;
|
|
412
461
|
const reportedArch = reporter.reporterArch ?? undefined;
|
|
413
462
|
const reportedMachineNickname = reporter.reporterMachineNickname ?? undefined;
|
|
414
|
-
|
|
463
|
+
const reportedProviderVersions = reporter.reporterProviderVersions ?? undefined;
|
|
464
|
+
const reportedDaemonBuildVersion = reporter.reporterDaemonBuildVersion ?? undefined;
|
|
465
|
+
if (
|
|
466
|
+
!reportedPlatform &&
|
|
467
|
+
!reportedArch &&
|
|
468
|
+
!reportedMachineNickname &&
|
|
469
|
+
!reportedProviderVersions &&
|
|
470
|
+
!reportedDaemonBuildVersion
|
|
471
|
+
) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
415
474
|
void import('../config/mesh-config.js')
|
|
416
|
-
.then(({ updateNode }) => updateNode(meshId, nodeId, {
|
|
475
|
+
.then(({ updateNode }) => updateNode(meshId, nodeId, {
|
|
476
|
+
reportedPlatform,
|
|
477
|
+
reportedArch,
|
|
478
|
+
reportedMachineNickname,
|
|
479
|
+
reportedProviderVersions,
|
|
480
|
+
reportedDaemonBuildVersion,
|
|
481
|
+
}))
|
|
417
482
|
.catch(() => { /* best-effort self-heal; never block status assembly */ });
|
|
418
483
|
}
|
|
419
484
|
|
|
@@ -1428,6 +1493,12 @@ async function probeRemoteMeshGitStatus(args: {
|
|
|
1428
1493
|
if (reporterPlatform) git.reporterPlatform = reporterPlatform;
|
|
1429
1494
|
if (reporterArch) git.reporterArch = reporterArch;
|
|
1430
1495
|
if (reporterMachineNickname) git.reporterMachineNickname = reporterMachineNickname;
|
|
1496
|
+
// T7: propagate the member's self-reported provider versions + build version on
|
|
1497
|
+
// the same reporter* channel so a remote node's providerVersions self-heal too.
|
|
1498
|
+
const reporterProviderVersions = readProviderVersionsRecord(remoteResult?.reporterProviderVersions);
|
|
1499
|
+
if (reporterProviderVersions) git.reporterProviderVersions = reporterProviderVersions;
|
|
1500
|
+
const reporterDaemonBuildVersion = readStringValue(remoteResult?.reporterDaemonBuildVersion);
|
|
1501
|
+
if (reporterDaemonBuildVersion) git.reporterDaemonBuildVersion = reporterDaemonBuildVersion;
|
|
1431
1502
|
return git;
|
|
1432
1503
|
}
|
|
1433
1504
|
|
|
@@ -45,7 +45,7 @@ import type { LocalMeshEntry } from '../repo-mesh-types.js';
|
|
|
45
45
|
import { loadConfig } from '../config/config.js';
|
|
46
46
|
import { listMeshes } from '../config/mesh-config.js';
|
|
47
47
|
import { LOG, getLogLevel } from '../logging/logger.js';
|
|
48
|
-
import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
48
|
+
import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, buildPendingEventFingerprint, queuePendingMeshCoordinatorEvent, serializeV2EnvelopeToWire } from './mesh-events-pending.js';
|
|
49
49
|
import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
|
|
50
50
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
51
51
|
import { MeshRuntimeStore } from './mesh-runtime-store.js';
|
|
@@ -2360,6 +2360,13 @@ function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
|
|
|
2360
2360
|
const tid = readNonEmptyString(metadata.taskId) || readNonEmptyString(metadata.meshActiveTaskId);
|
|
2361
2361
|
return tid ? { taskId: tid } : {};
|
|
2362
2362
|
})(),
|
|
2363
|
+
// T4 (B3b): carry the v2 envelope (protocolVersion/eventId/scope/dispatchedBy/
|
|
2364
|
+
// intendedFor) across the P2P relay boundary at the TOP LEVEL. These live on the
|
|
2365
|
+
// pending event itself, not inside metadataEvent, so without this the remote pull
|
|
2366
|
+
// re-queue would re-stamp a fresh eventId — breaking cross-machine idempotency and
|
|
2367
|
+
// downgrading the relayed completion to v1 broadcast routing. Spread LAST so the
|
|
2368
|
+
// authoritative envelope always wins over any stale key the metadata spread carried.
|
|
2369
|
+
...serializeV2EnvelopeToWire(event as PendingMeshCoordinatorEvent),
|
|
2363
2370
|
};
|
|
2364
2371
|
}
|
|
2365
2372
|
|
|
@@ -1917,6 +1917,36 @@ export class MeshRuntimeStore {
|
|
|
1917
1917
|
return row !== undefined;
|
|
1918
1918
|
}
|
|
1919
1919
|
|
|
1920
|
+
/**
|
|
1921
|
+
* B3a — v2 eventId idempotency. Returns true when a row with this event_id has
|
|
1922
|
+
* ALREADY been drained (drained = 1) for the mesh. Drained rows are retained
|
|
1923
|
+
* (soft-marked, not deleted until mesh deletion), so this is a durable, restart-
|
|
1924
|
+
* surviving dedup: a v2 event whose eventId was already consumed is skipped on
|
|
1925
|
+
* re-delivery even when its content fingerprint differs. Scoped by mesh_id +
|
|
1926
|
+
* the partial event_id index (idx_mesh_pending_events_event_id).
|
|
1927
|
+
*/
|
|
1928
|
+
hasDrainedEventId(meshId: string, eventId: string): boolean {
|
|
1929
|
+
if (!eventId) return false;
|
|
1930
|
+
const row = this.db.prepare(
|
|
1931
|
+
'SELECT 1 FROM mesh_pending_events WHERE mesh_id = ? AND event_id = ? AND drained = 1 LIMIT 1'
|
|
1932
|
+
).get(meshId, eventId);
|
|
1933
|
+
return row !== undefined;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/**
|
|
1937
|
+
* B3a — snapshot of the v2 event_ids ALREADY drained (drained = 1) for the mesh.
|
|
1938
|
+
* Taken BEFORE a drain call marks the current batch drained=1, so the resulting
|
|
1939
|
+
* set names only PRIOR drains — the re-delivery dedup baseline. (Reading it after
|
|
1940
|
+
* the drain would self-match the batch's own freshly-drained rows.) Non-v2 rows
|
|
1941
|
+
* have a NULL event_id and are excluded by the index/WHERE.
|
|
1942
|
+
*/
|
|
1943
|
+
drainedEventIdsForMesh(meshId: string): Set<string> {
|
|
1944
|
+
const rows = this.db.prepare(
|
|
1945
|
+
'SELECT DISTINCT event_id FROM mesh_pending_events WHERE mesh_id = ? AND drained = 1 AND event_id IS NOT NULL'
|
|
1946
|
+
).all(meshId) as Array<{ event_id: string }>;
|
|
1947
|
+
return new Set(rows.map(r => r.event_id));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1920
1950
|
// ── M3: Mission Records ─────────────────────────────────────────────────
|
|
1921
1951
|
|
|
1922
1952
|
upsertMission(mission: {
|