@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.
@@ -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';
@@ -258,6 +258,59 @@ const inFlightAckedHoldState = new Map<string, AckedHoldState>();
258
258
  // A restart resets this set, so the first touch of each mesh reloads from disk.
259
259
  const rehydratedHoldMeshes = new Set<string>();
260
260
 
261
+ // ─── T6 (B3c): PHASE-4 synthesis + acked-hold fast-track demoted to last-resort ──
262
+ //
263
+ // Under mesh-protocol-v2 enforce, the completion contract is explicit: a worker's
264
+ // terminal emit is a v2 unicast event drained straight to the coordinator. The
265
+ // PHASE-4 transcript-synthesis backstop and the acked-hold fast-track exist to
266
+ // paper over a LOST emit — they should NEVER fire once v2 delivery is healthy. So
267
+ // their firing is now a demoted last-resort signal: every fire bumps a counter, and
268
+ // under enforce a fire additionally emits a WARN naming it a v2-contract violation
269
+ // (a real emit was expected but never arrived). Target = 0 fires in steady state.
270
+ //
271
+ // The code is NOT removed — it stays as the correctness net for a genuinely lost
272
+ // emit (rollout plan §B3c: "코드 삭제는 하지 않고 관측 후 다음 사이클에 판단"). Process-
273
+ // lifetime totals; read by tests + surfaced in mesh_status.
274
+ const meshV2BackstopCounters = {
275
+ /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
276
+ phase4SynthesisFired: 0,
277
+ /** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
278
+ ackedHoldFastTrackFired: 0,
279
+ /** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
280
+ ackedHoldDeathDeadlineFired: 0,
281
+ };
282
+
283
+ /** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
284
+ export function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters> {
285
+ return { ...meshV2BackstopCounters };
286
+ }
287
+
288
+ /** Test helper: zero the backstop counters so a case starts from a clean slate. */
289
+ export function __resetMeshV2BackstopCountersForTests(): void {
290
+ for (const k of Object.keys(meshV2BackstopCounters) as Array<keyof typeof meshV2BackstopCounters>) {
291
+ meshV2BackstopCounters[k] = 0;
292
+ }
293
+ }
294
+
295
+ /** Enforce switch mirror (see isMeshProtocolV2EnforceEnabled in mesh-events-pending);
296
+ * re-read here (not imported) to keep the reconcile loop free of a cross-file coupling
297
+ * and to read env at fire time. Same truthy vocabulary. */
298
+ function meshProtocolV2EnforceOn(): boolean {
299
+ const raw = process.env.MESH_PROTOCOL_V2_ENFORCE;
300
+ if (typeof raw !== 'string') return false;
301
+ const v = raw.trim().toLowerCase();
302
+ return v === '1' || v === 'true' || v === 'on' || v === 'yes';
303
+ }
304
+
305
+ /** Bump a backstop counter and, under enforce, WARN that a last-resort net fired —
306
+ * which under a healthy v2 contract should not happen (the real emit was lost). */
307
+ function recordBackstopFire(kind: keyof typeof meshV2BackstopCounters, detail: string): void {
308
+ meshV2BackstopCounters[kind]++;
309
+ if (meshProtocolV2EnforceOn()) {
310
+ LOG.warn('MeshReconcileV2', `v2 ENFORCE last-resort backstop fired (${kind}): ${detail}. Under a healthy v2 completion contract this should be 0 — a worker's real terminal emit was lost/late.`);
311
+ }
312
+ }
313
+
261
314
  function inFlightSynthKey(meshId: string, taskId: string): string {
262
315
  return `${meshId}::${taskId}`;
263
316
  }
@@ -2011,6 +2064,12 @@ async function reconcileUnterminatedDirectDispatches(
2011
2064
 
2012
2065
  const synthKey = inFlightSynthKey(mesh.id, taskId);
2013
2066
  const isAcked = dispatch.status === 'acked';
2067
+ // T6: which last-resort backstop (if any) drove this synth. Set when the
2068
+ // acked-hold fast-track / death-deadline promotes the synth; the counter is
2069
+ // bumped only if the synth actually COMMITS (result.reconciled), so a
2070
+ // deferred/re-probed-away synth is not miscounted. A never-acked dispatch
2071
+ // that reaches the commit is a plain PHASE-4 transcript synthesis.
2072
+ let backstopKind: keyof ReturnType<typeof getMeshV2BackstopCounters> | undefined;
2014
2073
 
2015
2074
  // R4f: read the worker session. A FAILED read (transport error / success:false / no payload)
2016
2075
  // is no longer silently swallowed for an acked task — it is the liveness side of the
@@ -2130,6 +2189,7 @@ async function reconcileUnterminatedDirectDispatches(
2130
2189
  const idleHeldMs = nowMs - idleSinceMs;
2131
2190
  if (idleHeldMs >= fastTrackGraceMs) {
2132
2191
  fastTrackReady = true;
2192
+ backstopKind = 'ackedHoldFastTrackFired';
2133
2193
  LOG.info('MeshReconcile', `Acked-hold transcript fast-track: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) read idle WITH a final assistant message for ${Math.round(idleHeldMs / 1000)}s continuous (grace ${Math.round(fastTrackGraceMs / 1000)}s) — promoting the synth ahead of the ${Math.round(deathDeadlineMs / 1000)}s death backstop; the worker's real emit was lost/late and a later one no-ops idempotently.`);
2134
2194
  }
2135
2195
  } else if (holdState?.transcriptIdleSinceMs !== undefined) {
@@ -2144,6 +2204,7 @@ async function reconcileUnterminatedDirectDispatches(
2144
2204
  continue;
2145
2205
  }
2146
2206
  if (!fastTrackReady) {
2207
+ backstopKind = 'ackedHoldDeathDeadlineFired';
2147
2208
  LOG.warn('MeshReconcile', `Acked-hold death deadline reached: task ${taskId} on node ${nodeId} (mesh ${mesh.id}) still idle ${Math.round(sinceAckMs / 1000)}s after the ack (deadline ${Math.round(deathDeadlineMs / 1000)}s) — synthesizing the missing completion as a notification-loss net (a real emit, if it ever lands, no-ops idempotently).`);
2148
2209
  }
2149
2210
  }
@@ -2221,6 +2282,11 @@ async function reconcileUnterminatedDirectDispatches(
2221
2282
  source: 'daemon_reconcile_transcript_completion',
2222
2283
  });
2223
2284
  if (result.reconciled) {
2285
+ // T6: this synth actually committed → count the last-resort backstop fire.
2286
+ // An acked hold routes to the fast-track / death-deadline kind captured
2287
+ // above; a never-acked dispatch is a plain PHASE-4 transcript synthesis.
2288
+ // Under enforce, recordBackstopFire additionally WARNs (target = 0 fires).
2289
+ recordBackstopFire(backstopKind ?? 'phase4SynthesisFired', `task ${taskId} on node ${nodeId} (mesh ${mesh.id}), kind=${result.kind}`);
2224
2290
  LOG.info('MeshReconcile', `Synthesized missing completion (${result.kind}) for task ${taskId} on node ${nodeId} (mesh ${mesh.id})`);
2225
2291
  }
2226
2292
  } catch (e: any) {
@@ -2360,6 +2426,13 @@ function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
2360
2426
  const tid = readNonEmptyString(metadata.taskId) || readNonEmptyString(metadata.meshActiveTaskId);
2361
2427
  return tid ? { taskId: tid } : {};
2362
2428
  })(),
2429
+ // T4 (B3b): carry the v2 envelope (protocolVersion/eventId/scope/dispatchedBy/
2430
+ // intendedFor) across the P2P relay boundary at the TOP LEVEL. These live on the
2431
+ // pending event itself, not inside metadataEvent, so without this the remote pull
2432
+ // re-queue would re-stamp a fresh eventId — breaking cross-machine idempotency and
2433
+ // downgrading the relayed completion to v1 broadcast routing. Spread LAST so the
2434
+ // authoritative envelope always wins over any stale key the metadata spread carried.
2435
+ ...serializeV2EnvelopeToWire(event as PendingMeshCoordinatorEvent),
2363
2436
  };
2364
2437
  }
2365
2438
 
@@ -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: {
@@ -16,7 +16,7 @@ const DEFAULT_APPROVAL_POSITIVE_HINTS = [
16
16
  'always allow',
17
17
  ];
18
18
 
19
- function normalizeApprovalLabel(value: string): string {
19
+ export function normalizeApprovalLabel(value: string): string {
20
20
  return String(value || '')
21
21
  .toLowerCase()
22
22
  .replace(/^[\s\[(<{]*\d+(?:\s*[.)\]}>:-]|\s)+/, '')
@@ -27,7 +27,7 @@ import { shouldCollectTraceCategory } from '../logging/debug-config.js';
27
27
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
28
28
  import type { ChatMessage } from '../types.js';
29
29
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
30
- import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText } from './approval-utils.js';
30
+ import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText, normalizeApprovalLabel } from './approval-utils.js';
31
31
  import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.js';
32
32
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
33
33
  import { normalizeProviderSessionId } from './provider-session-id.js';
@@ -2317,19 +2317,31 @@ export class CliProviderInstance implements ProviderInstance {
2317
2317
  // kind gate). Surface the modal so the user decides; never pick blindly.
2318
2318
  return autoApproveActive;
2319
2319
  }
2320
- // Modal *identity* signature — the question/button set only, NO volatile
2321
- // counters. This is what the settle gate tracks: the FSM bumps
2322
- // approvalEntrySeq on every fresh waiting_approval entry, and a
2323
- // modal→generating→modal flap (the question line scrolled out of the
2324
- // captured frame while the button block stays) re-enters and bumps it
2325
- // again. Folding that seq into the settle signature made the 600ms
2326
- // settle clock restart on every flap, so the modal never stayed stable
2327
- // long enough to fire — the gate was never satisfied. Identity excludes
2328
- // the seq so button/seq flap of the SAME modal keeps one settle clock.
2320
+ // Modal *identity* signature — the question plus the STABLE affirmative
2321
+ // anchor only, NO volatile counters and NO raw button set. This is what
2322
+ // the settle gate tracks: the FSM bumps approvalEntrySeq on every fresh
2323
+ // waiting_approval entry, and a modal→generating→modal flap (the question
2324
+ // line scrolled out of the captured frame while the button block stays)
2325
+ // re-enters and bumps it again. Folding that seq into the settle signature
2326
+ // made the 600ms settle clock restart on every flap, so the modal never
2327
+ // stayed stable long enough to fire — the gate was never satisfied.
2328
+ // Identity excludes the seq so seq flap of the SAME modal keeps one clock.
2329
+ //
2330
+ // The raw button set is also excluded: on a TALL Write/Edit diff claude's
2331
+ // TUI repaints the button block 3↔5↔none between frames (buttons scroll in
2332
+ // and out of the captured region), which flipped both buttons.join('|') and
2333
+ // the positional buttonIndex every frame → signature flap → the settle
2334
+ // clock reset 4–9s and only mask-stalled episodes leaked to the coordinator
2335
+ // (AUTOAPPROVE-SETTLE-FLAP). The affirmative the auto-approve will actually
2336
+ // press is the invariant across those repaints, so we anchor on its
2337
+ // NORMALIZED label (numbers/bullets/punctuation stripped, so "1. Yes" and
2338
+ // "3. Yes" collapse to "yes"). Message + affirmative label uniquely
2339
+ // identifies the consent question without tracking the volatile button
2340
+ // positions.
2341
+ const affirmativeAnchor = normalizeApprovalLabel(buttonLabel);
2329
2342
  const modalSignature = [
2330
2343
  typeof modal?.message === 'string' ? modal.message.trim() : '',
2331
- buttons.join('|'),
2332
- buttonIndex,
2344
+ affirmativeAnchor,
2333
2345
  ].join('::');
2334
2346
  // Busy-window re-entry guard still needs the seq: two DISTINCT
2335
2347
  // back-to-back approvals can carry identical message/buttons (common
@@ -703,6 +703,23 @@ export interface RepoMeshNodeCapabilities {
703
703
  canPush?: boolean;
704
704
  readOnly?: boolean;
705
705
  userLabels?: string[];
706
+ /**
707
+ * Detected provider CLI/ACP versions on this node, keyed by provider id
708
+ * (e.g. `{ 'claude-cli': '1.2.3', 'codex-cli': '0.9.0' }`). Populated from the
709
+ * same CLI detection pass that feeds providerPriority (see buildProviderVersions
710
+ * over detectCLIs' CLIInfo[]). Absent/undefined when detection has not run or a
711
+ * daemon predates the exposure — never a hard signal, purely observability so a
712
+ * coordinator can spot a provider-version skew across nodes before dispatch.
713
+ * Additive: existing status consumers ignore it.
714
+ */
715
+ providerVersions?: Record<string, string>;
716
+ /**
717
+ * The daemon build version (package.json version baked into the running bundle,
718
+ * see getDaemonBuildInfo().version) that detected the above providerVersions.
719
+ * Complements the commit-level daemonBuild stamp with a human-readable version
720
+ * for node-card rendering. Absent when the build define was not injected.
721
+ */
722
+ daemonBuildVersion?: string;
706
723
  }
707
724
 
708
725
  export interface DetectedCommand {
@@ -849,6 +866,19 @@ export interface LocalMeshNodeEntry {
849
866
  */
850
867
  reportedPlatform?: string;
851
868
  reportedArch?: string;
869
+ /**
870
+ * Live, self-healed provider CLI/ACP versions reported by the daemon that owns
871
+ * this node's workspace, carried on the git_status envelope (reporterProviderVersions)
872
+ * and persisted by the coordinator on each direct git probe — mirrors the
873
+ * reportedPlatform/reportedArch self-heal pattern. Auto-detected truth, overwritten
874
+ * by the next report so a stale value never sticks. Absent until the first probe
875
+ * carrying versions succeeds. Surfaced as RepoMeshNodeStatus.providerVersions.
876
+ */
877
+ reportedProviderVersions?: Record<string, string>;
878
+ /** Live, self-healed daemon build version (getDaemonBuildInfo().version) of the
879
+ * owning daemon, carried on the git_status envelope (reporterDaemonBuildVersion)
880
+ * alongside the provider versions. Absent until first reported. */
881
+ reportedDaemonBuildVersion?: string;
852
882
  /**
853
883
  * The operator-set machine nickname (config.machineNickname) of the daemon
854
884
  * that owns this node's workspace. The local coordinator stamps its own
@@ -974,6 +1004,76 @@ export interface RepoMeshStatus {
974
1004
  * optional. Mirrors the MCP `mesh_status` tool's `magiActivity` field.
975
1005
  */
976
1006
  magiActivity?: MeshMagiActivitySummary[];
1007
+ /**
1008
+ * T7 (visibility 7-2b): provider CLI/ACP version skew across nodes. Each entry
1009
+ * names a provider running ≥2 distinct versions across the nodes that reported
1010
+ * it, with the node ids per version. Observational only — never a dispatch
1011
+ * blocker. Omitted when every reported provider is uniform (or none reported).
1012
+ * Mirrors the MCP `mesh_status` tool's `providerVersionSkew` field.
1013
+ */
1014
+ providerVersionSkew?: MeshProviderVersionSkew[];
1015
+ /** Human-readable companion warning to providerVersionSkew. Omitted when no skew. */
1016
+ providerVersionSkewWarning?: string;
1017
+ /**
1018
+ * T7 (B4): mesh-protocol-v2 adoption metrics for the batch of pending events
1019
+ * surfaced in the drain backing this status. Snapshot, not a durable counter.
1020
+ * Omitted when nothing was drained. Mirrors the MCP tool's meshProtocolMetrics.
1021
+ */
1022
+ meshProtocolMetrics?: MeshProtocolMetrics;
1023
+ /**
1024
+ * T6 (B3c): live process-lifetime mesh-protocol-v2 enforce counters from THIS
1025
+ * daemon — the enforce flag state, drain-routing tallies (deliver / route-away /
1026
+ * dedup / quarantine), and the last-resort backstop fire counts (PHASE-4 synth,
1027
+ * acked-hold fast-track / death-deadline). Diagnostic-only and never cached (a
1028
+ * live snapshot). Under enforce, non-zero quarantine or backstop counts are the
1029
+ * rollout-health signal (target 0). Omitted when unavailable.
1030
+ */
1031
+ meshProtocolV2Counters?: MeshProtocolV2Counters;
1032
+ }
1033
+
1034
+ /** T6 (B3c) live v2 enforce/observability counters (see RepoMeshStatus.meshProtocolV2Counters). */
1035
+ export interface MeshProtocolV2Counters {
1036
+ /** True when MESH_PROTOCOL_V2_ENFORCE is active on this daemon. */
1037
+ enforce: boolean;
1038
+ /** Drain-path routing tallies (accept + enforce). Process-lifetime totals. */
1039
+ drain: {
1040
+ v2Delivered: number;
1041
+ v2RoutedAway: number;
1042
+ v2DedupSkipped: number;
1043
+ v2ValidationFailedAccepted: number;
1044
+ v2ReattributedToDrainer: number;
1045
+ v1BroadcastAccepted: number;
1046
+ v2ValidationFailedQuarantined: number;
1047
+ v1UnversionedQuarantined: number;
1048
+ };
1049
+ /** Last-resort backstop fire counts. Target 0 under a healthy v2 contract. */
1050
+ backstop: {
1051
+ phase4SynthesisFired: number;
1052
+ ackedHoldFastTrackFired: number;
1053
+ ackedHoldDeathDeadlineFired: number;
1054
+ };
1055
+ }
1056
+
1057
+ /** One provider's version skew across mesh nodes (see RepoMeshStatus.providerVersionSkew). */
1058
+ export interface MeshProviderVersionSkew {
1059
+ /** Provider id (e.g. 'claude-cli'). */
1060
+ provider: string;
1061
+ /** Each distinct detected version and the node ids running it. */
1062
+ versions: Array<{ version: string; nodeIds: string[] }>;
1063
+ }
1064
+
1065
+ /** Mesh-protocol-v2 adoption snapshot over one drain (see RepoMeshStatus.meshProtocolMetrics). */
1066
+ export interface MeshProtocolMetrics {
1067
+ /** Total pending events surfaced in the drain. */
1068
+ total: number;
1069
+ /** Count carrying a v2 envelope (protocolVersion '2.0'). */
1070
+ v2: number;
1071
+ /** Count still on v1 (unstamped). */
1072
+ v1: number;
1073
+ /** v2/total, rounded to 2 decimals (0 when total is 0). */
1074
+ v2Ratio: number;
1075
+ /** Scope breakdown of the v2 events (unicast/broadcast/system/unspecified → count). */
1076
+ scopes: Record<string, number>;
977
1077
  }
978
1078
 
979
1079
  // RepoMeshSessionStatus shape now lives in @adhdev/mesh-shared (shared with
@@ -1026,6 +1126,17 @@ export interface RepoMeshNodeStatus {
1026
1126
  */
1027
1127
  gitProbePending?: boolean;
1028
1128
  providers: string[];
1129
+ /**
1130
+ * Detected provider CLI/ACP versions on this node, keyed by provider id. Mirrors
1131
+ * RepoMeshNodeCapabilities.providerVersions onto the status snapshot so the mesh
1132
+ * UI / coordinator prompt can render per-provider versions and flag a version
1133
+ * skew across nodes. Optional — omitted by daemons predating the exposure or when
1134
+ * detection has not run. Additive; existing consumers ignore it. */
1135
+ providerVersions?: Record<string, string>;
1136
+ /** Human-readable daemon build version (getDaemonBuildInfo().version) of the
1137
+ * daemon that owns this node. Complements the per-daemon commit stamp
1138
+ * (daemonBuilds) for node-card display. Omitted when unknown. */
1139
+ daemonBuildVersion?: string;
1029
1140
  activeSessions: string[];
1030
1141
  activeSessionDetails?: RepoMeshSessionStatus[];
1031
1142
  providerPriority?: string[];