@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.
- 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 +567 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +565 -27
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-pending.d.ts +75 -1
- package/dist/mesh/mesh-events.d.ts +2 -2
- package/dist/mesh/mesh-node-identity.d.ts +4 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +12 -0
- package/dist/mesh/mesh-runtime-store.d.ts +17 -0
- package/dist/providers/approval-utils.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +111 -0
- package/package.json +3 -3
- package/src/boot/daemon-lifecycle.ts +15 -2
- package/src/commands/high-family/mesh-events.ts +14 -1
- package/src/commands/high-family/mesh-status.ts +29 -2
- 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 +465 -5
- package/src/mesh/mesh-events.ts +5 -0
- package/src/mesh/mesh-node-identity.ts +77 -6
- package/src/mesh/mesh-reconcile-loop.ts +74 -1
- package/src/mesh/mesh-runtime-store.ts +30 -0
- package/src/providers/approval-utils.ts +1 -1
- package/src/providers/cli-provider-instance.ts +24 -12
- package/src/repo-mesh-types.ts +111 -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,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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
/**
|
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -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
|
-
): {
|
|
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
|
|