@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.
@@ -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
- return reconcilePendingMeshCoordinatorEvents(meshId, merged);
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(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[] {
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
- return reconcilePendingMeshCoordinatorEvents(meshId, merged);
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
  /**
@@ -11,6 +11,8 @@ export {
11
11
  drainPendingMeshCoordinatorEvents,
12
12
  getPendingMeshCoordinatorEvents,
13
13
  clearPendingMeshCoordinatorEvents,
14
+ serializeV2EnvelopeToWire,
15
+ readV2EnvelopeFromWire,
14
16
  } from './mesh-events-pending.js';
15
17
 
16
18
  export {
@@ -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
- ): { reporterPlatform: string | null; reporterArch: string | null; reporterMachineNickname: string | null } {
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 { reporterPlatform: null, reporterArch: null, reporterMachineNickname: null };
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
- return { reporterPlatform, reporterArch, reporterMachineNickname };
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: { reporterPlatform: string | null; reporterArch: string | null; reporterMachineNickname?: string | null },
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
- if (!reportedPlatform && !reportedArch && !reportedMachineNickname) return;
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, { reportedPlatform, reportedArch, reportedMachineNickname }))
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: {