@adhdev/daemon-core 0.9.82-rc.460 → 0.9.82-rc.462

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,351 @@ 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
+ /**
138
+ * T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
139
+ * (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
140
+ * it instead (excluded from the delivered batch + WARN + counter), and unicast
141
+ * routing is the only delivery path (there is no v1 broadcast fallback). Off (the
142
+ * default) preserves the accept-and-warn rollout behaviour exactly.
143
+ *
144
+ * Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
145
+ * — its activation is a deliberate operational step taken ONLY after daemonBuilds
146
+ * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
147
+ * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
148
+ * no data migration — the schema is additive). Read at call time so a test /
149
+ * operator can toggle it without a restart.
150
+ *
151
+ * Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
152
+ * already consumed the event from its store by the time routing runs, so "held
153
+ * back" here means: excluded from the delivered batch AND mirrored into the mesh
154
+ * ledger as a recoverable `event_held` entry (the same recovery channel the
155
+ * pending-trim path uses). It is observable via the counters + the ledger, so an
156
+ * operator can requeue it after fixing the producer. The non-destructive PEEK path
157
+ * (countMetrics=false) merely omits the event from the returned list — it never
158
+ * consumed it and must not ledger-record on every status poll.
159
+ */
160
+ export function isMeshProtocolV2EnforceEnabled(): boolean {
161
+ const raw = readNonEmptyString(process.env.MESH_PROTOCOL_V2_ENFORCE);
162
+ if (!raw) return false;
163
+ const v = raw.trim().toLowerCase();
164
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes';
165
+ }
166
+
167
+ /**
168
+ * Record a v2-enforce-quarantined event into the mesh ledger as recoverable, so a
169
+ * destructively-drained event held back by enforce is auditable and requeue-able
170
+ * (loss-free invariant). Mirrors the pending-trim `event_held` shape. Best-effort:
171
+ * a ledger write failure must not break the drain. Called ONLY on the destructive
172
+ * drain path (the peek path never consumed the event, so nothing to recover).
173
+ */
174
+ function ledgerRecordQuarantinedEvent(event: PendingMeshCoordinatorEvent, reason: string): void {
175
+ try {
176
+ const finalSummary = readMeshCompletionSummary(event.metadataEvent || {});
177
+ appendLedgerEntry(event.meshId, {
178
+ kind: 'event_held',
179
+ ...(event.nodeId ? { nodeId: event.nodeId } : {}),
180
+ payload: {
181
+ event: event.event,
182
+ reason,
183
+ recoverable: true,
184
+ nodeLabel: event.nodeLabel,
185
+ ...(event.workspace ? { workspace: event.workspace } : {}),
186
+ targetCoordinatorDaemonId: event.targetCoordinatorDaemonId ?? null,
187
+ ...(readNonEmptyString(event.eventId) ? { eventId: event.eventId } : {}),
188
+ queuedAt: event.queuedAt,
189
+ ...(finalSummary ? { finalSummary } : {}),
190
+ },
191
+ });
192
+ } catch (e: any) {
193
+ LOG.warn('MeshEventsV2', `Failed to ledger-record v2-quarantined ${event.event} for mesh ${event.meshId}: ${e?.message || e}`);
194
+ }
195
+ }
196
+
197
+ /** Observability counters for the v2 drain path. Read by tests and surfaced in
198
+ * mesh_status (B4/T6). Process-lifetime totals — never reset in production. */
199
+ const meshV2DrainCounters = {
200
+ /** v2 events that passed validation and unicast/broadcast routing → delivered. */
201
+ v2Delivered: 0,
202
+ /** v2 unicast events skipped because intendedFor addressed another coordinator. */
203
+ v2RoutedAway: 0,
204
+ /** v2 events skipped because their eventId was already drained (idempotency). */
205
+ v2DedupSkipped: 0,
206
+ /** v2 events that failed assertPendingMeshCoordinatorEventV2 but were PASSED
207
+ * THROUGH (accept mode). Non-zero here is the rollout signal that a producer
208
+ * emits a malformed envelope. */
209
+ v2ValidationFailedAccepted: 0,
210
+ /** unicast events re-attributed to the drainer via daemon-core match (a
211
+ * coordinatorRunId change orphaned them). */
212
+ v2ReattributedToDrainer: 0,
213
+ /** v1 (unversioned) events passed through as broadcast (rollout baseline). */
214
+ v1BroadcastAccepted: 0,
215
+ /** T6 enforce: v2 events that FAILED validation and were QUARANTINED (held back
216
+ * from delivery, not dropped). Non-zero here means a producer is still emitting a
217
+ * malformed envelope after enforce was turned on. */
218
+ v2ValidationFailedQuarantined: 0,
219
+ /** T6 enforce: v1 (unversioned) events QUARANTINED because no v2 envelope could be
220
+ * derived at emit time. Non-zero here means a producer path still emits v1 after
221
+ * enforce — it should reach 0 once every node is on a v2-stamping build. */
222
+ v1UnversionedQuarantined: 0,
223
+ };
224
+
225
+ /** Test/observability accessor for the v2 drain counters (snapshot copy). */
226
+ export function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters> {
227
+ return { ...meshV2DrainCounters };
228
+ }
229
+
230
+ /** Test helper: zero the v2 drain counters so a test starts from a clean slate. */
231
+ export function __resetMeshV2DrainCountersForTests(): void {
232
+ for (const k of Object.keys(meshV2DrainCounters) as Array<keyof typeof meshV2DrainCounters>) {
233
+ meshV2DrainCounters[k] = 0;
234
+ }
235
+ }
236
+
237
+ // One-shot WARN dedup: an accept-mode warning is logged once per (meshId, eventId)
238
+ // so a re-polled malformed event doesn't spam the log every 4s reconcile tick.
239
+ const warnedV2Violations = new Set<string>();
240
+ function warnV2Once(key: string, message: string): void {
241
+ if (warnedV2Violations.has(key)) return;
242
+ warnedV2Violations.add(key);
243
+ // Bound the set so a long-lived daemon churning many distinct eventIds can't leak.
244
+ if (warnedV2Violations.size > 2000) {
245
+ const first = warnedV2Violations.values().next().value;
246
+ if (first !== undefined) warnedV2Violations.delete(first);
247
+ }
248
+ LOG.warn('MeshEventsV2', message);
249
+ }
250
+
251
+ /** Test helper: clear the one-shot WARN dedup set. */
252
+ export function __resetMeshV2WarnDedupForTests(): void {
253
+ warnedV2Violations.clear();
254
+ }
255
+
256
+ /**
257
+ * Resolve the drainer's CoordinatorIdentity for v2 routing from the daemon-id
258
+ * argument the (untouchable) reconcile-loop already passes. The daemon ids are the
259
+ * dual/expanded self-identity forms from resolveCoordinatorDaemonIds; the FIRST is
260
+ * the primary. coordinatorRunId is not threaded through the drain call yet, so it
261
+ * falls back to the daemonId exactly as the emit side does
262
+ * (coordinatorIdentityFromEmitFields) — this keeps drain-side identity CONSISTENT
263
+ * with how v1→v2 events were stamped, and unicast equality then reduces to the
264
+ * daemon-core match, which is the correct rollout-window granularity. A caller that
265
+ * knows the full identity (with a real coordinatorRunId) may pass it explicitly to
266
+ * override. Returns undefined when no daemon id is known (→ v2 routing is a no-op,
267
+ * everything passes as-is).
268
+ */
269
+ function resolveDrainerIdentity(
270
+ daemonIds: ReadonlyArray<string>,
271
+ explicit?: CoordinatorIdentity,
272
+ ): CoordinatorIdentity | undefined {
273
+ if (explicit) return explicit;
274
+ return coordinatorIdentityFromEmitFields({ daemonId: daemonIds[0] });
275
+ }
276
+
277
+ /** A v2 event carries a '2.0' protocolVersion. Everything else is a v1 event. */
278
+ function isV2Event(event: PendingMeshCoordinatorEvent): boolean {
279
+ return event.protocolVersion === MESH_PROTOCOL_VERSION_V2;
280
+ }
281
+
282
+ /**
283
+ * True when an identity's coordinatorRunId is merely its own daemonId form (the
284
+ * B2a fallback in coordinatorIdentityFromEmitFields — no real coordinatorRunId was
285
+ * threaded through the emit/drain site yet). For such an identity the runId carries
286
+ * NO information beyond the daemon, so two different daemon-id FORMS of the same
287
+ * machine (mach_ vs daemon_mach_) must not be treated as different coordinators.
288
+ */
289
+ function runIdIsDaemonFormFallback(identity: CoordinatorIdentity): boolean {
290
+ return daemonIdsEquivalent(identity.coordinatorRunId, identity.daemonId);
291
+ }
292
+
293
+ /**
294
+ * Delivery equality for the rollout window. When BOTH sides carry only a
295
+ * daemon-form-fallback runId (no real coordinatorRunId wired yet), a match reduces
296
+ * to same-machine — so a completion stamped `daemon_mach_X` is delivered to a
297
+ * coordinator that knows itself as bare `mach_X` (the canon-identity heterogeneous-
298
+ * form case). The session is compared ONLY when BOTH sides carry one: a session-less
299
+ * drainer is a daemon-level drain (what the reconcile loop passes) that accepts any
300
+ * session's events on that machine — targetCoordinatorSessionId is a PHASE-2 inject
301
+ * key, not a drain-scoping key (see the v1 field comment). When only the drainer AND
302
+ * the event both name a session do we require them to match, so a session-specific
303
+ * coordinator does not receive a sibling session's unicast event.
304
+ *
305
+ * When EITHER side has a real (non-daemon-form) runId, fall back to strict
306
+ * coordinatorIdentityEquals so two genuinely distinct coordinators on the same
307
+ * daemon (different real runIds) stay separated.
308
+ */
309
+ function identityDeliversTo(intendedFor: CoordinatorIdentity, drainer: CoordinatorIdentity): boolean {
310
+ if (runIdIsDaemonFormFallback(intendedFor) && runIdIsDaemonFormFallback(drainer)) {
311
+ if (!daemonIdsEquivalent(intendedFor.daemonId, drainer.daemonId)) return false;
312
+ // Session filter applies only when the drainer itself is session-specific.
313
+ if (intendedFor.sessionId && drainer.sessionId) {
314
+ return intendedFor.sessionId === drainer.sessionId;
315
+ }
316
+ return true;
317
+ }
318
+ return coordinatorIdentityEquals(intendedFor, drainer);
319
+ }
320
+
321
+ /**
322
+ * Apply v2 receive-side routing + idempotency to a merged, already daemon-scoped
323
+ * candidate list (accept-and-warn — never drops on validation failure).
324
+ *
325
+ * - `drainer` undefined → routing is skipped, list returned unchanged (safety).
326
+ * - Marks each surviving v2 event's eventId in `batchSeen` so a same-batch dup is
327
+ * skipped; a caller that persists drains uses `alreadyDrained` for the durable
328
+ * check (getPending peek passes a no-op so a peek never dedups against itself).
329
+ */
330
+ function routeV2EventsForDrainer(
331
+ events: PendingMeshCoordinatorEvent[],
332
+ drainer: CoordinatorIdentity | undefined,
333
+ ctx: {
334
+ alreadyDrained: (eventId: string) => boolean;
335
+ batchSeen: Set<string>;
336
+ /** false for a non-destructive peek so the frequent status-poll path does
337
+ * not inflate the delivery counters (only the real drain counts). */
338
+ countMetrics: boolean;
339
+ },
340
+ ): PendingMeshCoordinatorEvent[] {
341
+ if (!drainer) return events;
342
+ // Read the enforce flag ONCE per drain so the whole batch is classified under a
343
+ // single, consistent policy (a mid-batch env flip cannot split one drain).
344
+ const enforce = isMeshProtocolV2EnforceEnabled();
345
+ const bump = (k: keyof typeof meshV2DrainCounters) => { if (ctx.countMetrics) meshV2DrainCounters[k]++; };
346
+ const kept: PendingMeshCoordinatorEvent[] = [];
347
+ for (const event of events) {
348
+ if (!isV2Event(event)) {
349
+ // v1 / unversioned event. ACCEPT MODE: broadcast during rollout (existing
350
+ // policy). ENFORCE MODE: quarantine — an unversioned event has no scope, so
351
+ // there is no safe unicast target; hold it back (not delivered) and mirror
352
+ // it to the ledger as recoverable, with a one-shot WARN + counter.
353
+ if (enforce) {
354
+ bump('v1UnversionedQuarantined');
355
+ if (ctx.countMetrics) ledgerRecordQuarantinedEvent(event, 'v2_enforce_unversioned_quarantined');
356
+ warnV2Once(
357
+ `${event.meshId}::${event.eventId ?? event.event}::v1-quarantined`,
358
+ `v2 ENFORCE: unversioned ${event.event} on mesh ${event.meshId} QUARANTINED (no v2 envelope — held back, not delivered; ledger-recorded recoverable). A producer path still emits v1.`,
359
+ );
360
+ continue;
361
+ }
362
+ bump('v1BroadcastAccepted');
363
+ kept.push(event);
364
+ continue;
365
+ }
366
+
367
+ // Validate the v2 envelope. ACCEPT MODE: a validation failure does NOT drop
368
+ // the event — it passes through with a one-shot WARN + counter. ENFORCE MODE:
369
+ // a validation failure is QUARANTINED (held back, not delivered) — the malformed
370
+ // envelope carries no trustworthy scope/target, so delivering it risks a
371
+ // cross-surface. It is ledger-recorded recoverable on the destructive path.
372
+ let validated: PendingMeshCoordinatorEventV2;
373
+ try {
374
+ validated = assertPendingMeshCoordinatorEventV2(event);
375
+ } catch (e: any) {
376
+ if (enforce) {
377
+ bump('v2ValidationFailedQuarantined');
378
+ if (ctx.countMetrics) ledgerRecordQuarantinedEvent(event, 'v2_enforce_validation_failed_quarantined');
379
+ warnV2Once(
380
+ `${event.meshId}::${event.eventId ?? event.event}::invalid-quarantined`,
381
+ `v2 ENFORCE: envelope validation failed for ${event.event} on mesh ${event.meshId} — QUARANTINED (held back, not delivered; ledger-recorded recoverable): ${e?.message || e}`,
382
+ );
383
+ continue;
384
+ }
385
+ bump('v2ValidationFailedAccepted');
386
+ warnV2Once(
387
+ `${event.meshId}::${event.eventId ?? event.event}::invalid`,
388
+ `v2 envelope validation failed for ${event.event} on mesh ${event.meshId} — PASSED THROUGH (accept mode): ${e?.message || e}`,
389
+ );
390
+ kept.push(event);
391
+ continue;
392
+ }
393
+
394
+ // eventId idempotency: skip if already drained (durable) or already seen in
395
+ // this same batch (guards the SQLite+JSONL dual-store merge duplicate).
396
+ const eventId = validated.eventId;
397
+ if (ctx.batchSeen.has(eventId) || ctx.alreadyDrained(eventId)) {
398
+ bump('v2DedupSkipped');
399
+ continue;
400
+ }
401
+
402
+ // Broadcast → any coordinator; system → daemon handler only (never a
403
+ // coordinator). Delegates to the contract helper for those two scopes.
404
+ if (validated.scope !== 'unicast') {
405
+ if (shouldDeliverPendingEventToCoordinator(validated, drainer)) {
406
+ ctx.batchSeen.add(eventId);
407
+ bump('v2Delivered');
408
+ kept.push(event);
409
+ } else {
410
+ // system scope → not for any coordinator.
411
+ bump('v2RoutedAway');
412
+ }
413
+ continue;
414
+ }
415
+
416
+ // Unicast: deliver iff intendedFor addresses THIS drainer. identityDeliversTo
417
+ // treats a daemon-form-fallback runId (no real coordinatorRunId wired yet) as
418
+ // form-agnostic so a `daemon_mach_X`-addressed event reaches a bare-`mach_X`
419
+ // drainer (heterogeneous-form same coordinator), while keeping two REAL
420
+ // distinct runIds on one daemon separated.
421
+ if (validated.intendedFor && identityDeliversTo(validated.intendedFor, drainer)) {
422
+ ctx.batchSeen.add(eventId);
423
+ bump('v2Delivered');
424
+ kept.push(event);
425
+ continue;
426
+ }
427
+
428
+ // Not delivered by identity. Apply the re-attribution fallback (plan risk
429
+ // §4): if intendedFor addresses the SAME MACHINE as the drainer AND the
430
+ // mismatch is a genuine coordinatorRunId change (a restart minted a fresh
431
+ // runId), deliver it to the current coordinator rather than orphaning it.
432
+ //
433
+ // Guard: when BOTH sides carry only a daemon-form-fallback runId, a mismatch
434
+ // that survived identityDeliversTo is a SESSION mismatch (a sibling
435
+ // coordinator on the same daemon) — that is a legitimate route-away, NOT an
436
+ // orphaned event, so re-attribution must not fire. Re-attribution requires a
437
+ // REAL runId difference, which means at least one side carries a real runId.
438
+ const realRunIdMismatch = !runIdIsDaemonFormFallback(validated.intendedFor!)
439
+ || !runIdIsDaemonFormFallback(drainer);
440
+ if (
441
+ validated.intendedFor
442
+ && realRunIdMismatch
443
+ && daemonIdsEquivalent(validated.intendedFor.daemonId, drainer.daemonId)
444
+ ) {
445
+ ctx.batchSeen.add(eventId);
446
+ bump('v2ReattributedToDrainer');
447
+ warnV2Once(
448
+ `${event.meshId}::${eventId}::reattributed`,
449
+ `v2 unicast ${event.event} on mesh ${event.meshId} re-attributed to current coordinator ${coordinatorIdentityKey(drainer)} (originating coordinatorRunId no longer live)`,
450
+ );
451
+ kept.push(event);
452
+ continue;
453
+ }
454
+
455
+ // Addressed to a genuinely different coordinator (different machine, or
456
+ // system scope) → not for this drainer. Skipped (left for its own drainer).
457
+ bump('v2RoutedAway');
458
+ }
459
+ return kept;
460
+ }
461
+
111
462
  export function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
112
463
  const metadata = readRecord((event as any).metadataEvent) || event as Record<string, unknown>;
113
464
  const result = readRecord(metadata.result);
@@ -422,6 +773,74 @@ export function stampPendingEventV2(
422
773
  };
423
774
  }
424
775
 
776
+ // ─── v2 envelope: remote (P2P) boundary preservation (B3b/T4) ─────────────
777
+ //
778
+ // The remote pull round-trip (mesh-reconcile-loop pullRemoteNodeQueues →
779
+ // get_pending_mesh_events → buildForwardPayloadFromPending → handleMeshForwardEvent
780
+ // → queuePendingMeshCoordinatorEvent) flattens a queued PendingMeshCoordinatorEvent
781
+ // into a flat wire payload and rebuilds it on the receiving daemon. The v2 envelope
782
+ // fields (protocolVersion / eventId / scope / dispatchedBy / intendedFor) live at the
783
+ // TOP LEVEL of the event, not inside metadataEvent, so the flatten/rebuild whitelist
784
+ // dropped them: the re-queue then re-stamped a FRESH eventId, breaking cross-machine
785
+ // idempotency and downgrading the relayed completion to v1 (broadcast) routing.
786
+ //
787
+ // These two helpers are the single serialization/deserialization pair for that
788
+ // boundary. serializeV2EnvelopeToWire copies the present v2 fields onto the flat
789
+ // payload; readV2EnvelopeFromWire validates and restores them for the re-queue. The
790
+ // eventId is carried verbatim so stampPendingEventV2's already-stamped short-circuit
791
+ // preserves it (no new UUID). Kept pure + exported so the round-trip is unit-testable.
792
+
793
+ /** Read a CoordinatorIdentity off an untrusted wire object, or undefined if malformed. */
794
+ function readCoordinatorIdentityFromWire(raw: unknown): CoordinatorIdentity | undefined {
795
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
796
+ const obj = raw as Record<string, unknown>;
797
+ const daemonId = readNonEmptyString(obj.daemonId);
798
+ const coordinatorRunId = readNonEmptyString(obj.coordinatorRunId);
799
+ if (!daemonId || !coordinatorRunId) return undefined;
800
+ const sessionId = readNonEmptyString(obj.sessionId);
801
+ return { daemonId, coordinatorRunId, ...(sessionId ? { sessionId } : {}) };
802
+ }
803
+
804
+ /**
805
+ * Copy the v2 envelope fields that are present on `event` onto a flat wire
806
+ * payload. Only sets a field when it is present, so a v1 event contributes
807
+ * nothing (the payload stays v1-shaped and version-skew safe).
808
+ */
809
+ export function serializeV2EnvelopeToWire(event: PendingMeshCoordinatorEvent): Record<string, unknown> {
810
+ const out: Record<string, unknown> = {};
811
+ if (event.protocolVersion) out.protocolVersion = event.protocolVersion;
812
+ if (readNonEmptyString(event.eventId)) out.eventId = event.eventId;
813
+ if (event.scope) out.scope = event.scope;
814
+ if (event.dispatchedBy) out.dispatchedBy = event.dispatchedBy;
815
+ if (event.intendedFor) out.intendedFor = event.intendedFor;
816
+ return out;
817
+ }
818
+
819
+ /**
820
+ * Restore the v2 envelope fields from a flat wire payload for a re-queue. Only
821
+ * returns fields that survive validation; a payload missing/malforming a field
822
+ * yields a partial (or empty) object so the re-queue path stays v1-safe. The
823
+ * eventId is returned verbatim — its preservation is the idempotency guarantee.
824
+ */
825
+ export function readV2EnvelopeFromWire(payload: Record<string, unknown>): Partial<Pick<
826
+ PendingMeshCoordinatorEvent,
827
+ 'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
828
+ >> {
829
+ const out: Partial<Pick<
830
+ PendingMeshCoordinatorEvent,
831
+ 'protocolVersion' | 'eventId' | 'scope' | 'dispatchedBy' | 'intendedFor'
832
+ >> = {};
833
+ if (payload.protocolVersion === MESH_PROTOCOL_VERSION_V2) out.protocolVersion = MESH_PROTOCOL_VERSION_V2;
834
+ const eventId = readNonEmptyString(payload.eventId);
835
+ if (eventId) out.eventId = eventId;
836
+ if (isMeshEventScope(payload.scope)) out.scope = payload.scope;
837
+ const dispatchedBy = readCoordinatorIdentityFromWire(payload.dispatchedBy);
838
+ if (dispatchedBy) out.dispatchedBy = dispatchedBy;
839
+ const intendedFor = readCoordinatorIdentityFromWire(payload.intendedFor);
840
+ if (intendedFor) out.intendedFor = intendedFor;
841
+ return out;
842
+ }
843
+
425
844
  export function queuePendingMeshCoordinatorEvent(
426
845
  rawEvent: PendingMeshCoordinatorEvent,
427
846
  hint?: PendingEventEmitHint,
@@ -569,7 +988,7 @@ function selectiveDrainFile(
569
988
  export function drainPendingMeshCoordinatorEvents(
570
989
  meshId?: string,
571
990
  coordinatorDaemonId?: string | ReadonlyArray<string>,
572
- opts?: { onlyEvents?: ReadonlySet<string> },
991
+ opts?: { onlyEvents?: ReadonlySet<string>; drainerIdentity?: CoordinatorIdentity },
573
992
  ): PendingMeshCoordinatorEvent[] {
574
993
  if (!meshId) return [];
575
994
 
@@ -580,6 +999,18 @@ export function drainPendingMeshCoordinatorEvents(
580
999
  const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
581
1000
  const primaryDaemonId = daemonIds[0];
582
1001
 
1002
+ // B3a: the drainer's v2 identity, for unicast routing + eventId dedup. Derived
1003
+ // from the daemon ids the (untouchable) reconcile-loop already passes, so no
1004
+ // caller change is required; a caller may still pass the full identity.
1005
+ const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
1006
+ // Snapshot the ALREADY-drained v2 eventIds BEFORE the SQLite drain marks this
1007
+ // batch drained=1 — the re-delivery dedup baseline. Reading it after would
1008
+ // self-match the batch's own rows.
1009
+ let priorDrainedEventIds = new Set<string>();
1010
+ try {
1011
+ priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
1012
+ } catch { /* store unavailable — no durable baseline; batch guard still applies */ }
1013
+
583
1014
  const onlyEvents = opts?.onlyEvents;
584
1015
  const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
585
1016
 
@@ -648,7 +1079,17 @@ export function drainPendingMeshCoordinatorEvents(
648
1079
  // longer exists — delivery is now queue-drain-only (reconcile loop or MCP pull),
649
1080
  // so an event is consumed by exactly one drainer via the atomic SQLite drained=1
650
1081
  // marking. There is no PTY-vs-poll double path left to dedup against.
651
- return reconcilePendingMeshCoordinatorEvents(meshId, merged);
1082
+ //
1083
+ // B3a: v2 receive-side routing (accept-and-warn) — unicast targeting, eventId
1084
+ // idempotency, malformed-envelope pass-through-with-warn. v1 events broadcast.
1085
+ // Runs AFTER the merge/reconcile so a single eventId dedup batch covers both
1086
+ // stores. Non-destructive to v1 behaviour when no drainer identity is known.
1087
+ const routed = routeV2EventsForDrainer(merged, drainer, {
1088
+ alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
1089
+ batchSeen: new Set<string>(),
1090
+ countMetrics: true,
1091
+ });
1092
+ return reconcilePendingMeshCoordinatorEvents(meshId, routed);
652
1093
  }
653
1094
 
654
1095
  /**
@@ -703,9 +1144,21 @@ export function retractPendingDispatchBlockedEvent(
703
1144
  }
704
1145
 
705
1146
  /** Peek at pending coordinator events without draining (non-destructive). */
706
- export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[] {
1147
+ export function getPendingMeshCoordinatorEvents(
1148
+ meshId?: string,
1149
+ coordinatorDaemonId?: string | ReadonlyArray<string>,
1150
+ opts?: { drainerIdentity?: CoordinatorIdentity },
1151
+ ): readonly PendingMeshCoordinatorEvent[] {
707
1152
  if (!meshId) return [];
708
1153
  const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
1154
+ // B3a: same v2 routing the destructive drain applies, so a peek (mesh_status
1155
+ // count, reconcile pre-check) sees the SAME set the drain would deliver — a
1156
+ // unicast event for another coordinator is not counted for this one.
1157
+ const drainer = resolveDrainerIdentity(daemonIds, opts?.drainerIdentity);
1158
+ let priorDrainedEventIds = new Set<string>();
1159
+ try {
1160
+ priorDrainedEventIds = MeshRuntimeStore.getInstance().drainedEventIdsForMesh(meshId);
1161
+ } catch { /* store unavailable — batch guard still applies */ }
709
1162
 
710
1163
  // Merge SQLite (primary) + JSONL (legacy) with fingerprint dedup.
711
1164
  const merged: PendingMeshCoordinatorEvent[] = [];
@@ -737,7 +1190,14 @@ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaem
737
1190
 
738
1191
  // (Former R3 direct-delivered filter removed — no PTY direct-inject path exists
739
1192
  // anymore, so a peeked pending event has genuinely not yet been consumed.)
740
- return reconcilePendingMeshCoordinatorEvents(meshId, merged);
1193
+ // B3a: apply the SAME v2 routing the drain applies (non-destructive: no counter
1194
+ // inflation on the frequent status-poll path).
1195
+ const routed = routeV2EventsForDrainer(merged, drainer, {
1196
+ alreadyDrained: (eventId) => priorDrainedEventIds.has(eventId),
1197
+ batchSeen: new Set<string>(),
1198
+ countMetrics: false,
1199
+ });
1200
+ return reconcilePendingMeshCoordinatorEvents(meshId, routed);
741
1201
  }
742
1202
 
743
1203
  /**
@@ -11,6 +11,10 @@ export {
11
11
  drainPendingMeshCoordinatorEvents,
12
12
  getPendingMeshCoordinatorEvents,
13
13
  clearPendingMeshCoordinatorEvents,
14
+ serializeV2EnvelopeToWire,
15
+ readV2EnvelopeFromWire,
16
+ getMeshV2DrainCounters,
17
+ isMeshProtocolV2EnforceEnabled,
14
18
  } from './mesh-events-pending.js';
15
19
 
16
20
  export {
@@ -22,6 +26,7 @@ export {
22
26
  runMeshReconcileTick,
23
27
  resolveCoordinatorDrainDeliverability,
24
28
  shouldHoldPendingDrainForBusyLocalCoordinator,
29
+ getMeshV2BackstopCounters,
25
30
  } from './mesh-reconcile-loop.js';
26
31
 
27
32
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
@@ -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