@adhdev/daemon-core 0.9.82-rc.461 → 0.9.82-rc.463

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.
@@ -52,8 +52,32 @@ export interface PendingEventEmitHint {
52
52
  /** coordinatorRunId to fold into the derived identity when the event lacks one. */
53
53
  coordinatorRunId?: string;
54
54
  }
55
- /** Observability counters for the accept-and-warn rollout. Read by tests and (later,
56
- * B4) surfaced in mesh_status. Process-lifetime totals never reset in production. */
55
+ /**
56
+ * T6 (B3c) enforce switch. When ON, the drain path stops passing an unversioned
57
+ * (v1) or a validation-failing v2 event through to the coordinator — it QUARANTINES
58
+ * it instead (excluded from the delivered batch + WARN + counter), and unicast
59
+ * routing is the only delivery path (there is no v1 broadcast fallback). Off (the
60
+ * default) preserves the accept-and-warn rollout behaviour exactly.
61
+ *
62
+ * Per the rollout plan (§1 decision 4): enforce is `MESH_PROTOCOL_V2_ENFORCE` (env)
63
+ * — its activation is a deliberate operational step taken ONLY after daemonBuilds
64
+ * confirms every node emits v2 (§배포 게이트 1 / risk §4). So the code default is
65
+ * OFF; flipping the env back to accept mode is a pure-env rollback (no code change,
66
+ * no data migration — the schema is additive). Read at call time so a test /
67
+ * operator can toggle it without a restart.
68
+ *
69
+ * Quarantine (not drop) keeps the loss-free invariant. The DESTRUCTIVE drain has
70
+ * already consumed the event from its store by the time routing runs, so "held
71
+ * back" here means: excluded from the delivered batch AND mirrored into the mesh
72
+ * ledger as a recoverable `event_held` entry (the same recovery channel the
73
+ * pending-trim path uses). It is observable via the counters + the ledger, so an
74
+ * operator can requeue it after fixing the producer. The non-destructive PEEK path
75
+ * (countMetrics=false) merely omits the event from the returned list — it never
76
+ * consumed it and must not ledger-record on every status poll.
77
+ */
78
+ export declare function isMeshProtocolV2EnforceEnabled(): boolean;
79
+ /** Observability counters for the v2 drain path. Read by tests and surfaced in
80
+ * mesh_status (B4/T6). Process-lifetime totals — never reset in production. */
57
81
  declare const meshV2DrainCounters: {
58
82
  /** v2 events that passed validation and unicast/broadcast routing → delivered. */
59
83
  v2Delivered: number;
@@ -70,6 +94,14 @@ declare const meshV2DrainCounters: {
70
94
  v2ReattributedToDrainer: number;
71
95
  /** v1 (unversioned) events passed through as broadcast (rollout baseline). */
72
96
  v1BroadcastAccepted: number;
97
+ /** T6 enforce: v2 events that FAILED validation and were QUARANTINED (held back
98
+ * from delivery, not dropped). Non-zero here means a producer is still emitting a
99
+ * malformed envelope after enforce was turned on. */
100
+ v2ValidationFailedQuarantined: number;
101
+ /** T6 enforce: v1 (unversioned) events QUARANTINED because no v2 envelope could be
102
+ * derived at emit time. Non-zero here means a producer path still emits v1 after
103
+ * enforce — it should reach 0 once every node is on a v2-stamping build. */
104
+ v1UnversionedQuarantined: number;
73
105
  };
74
106
  /** Test/observability accessor for the v2 drain counters (snapshot copy). */
75
107
  export declare function getMeshV2DrainCounters(): Readonly<typeof meshV2DrainCounters>;
@@ -1,6 +1,6 @@
1
1
  export type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
2
- export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, serializeV2EnvelopeToWire, readV2EnvelopeFromWire, } from './mesh-events-pending.js';
2
+ export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, serializeV2EnvelopeToWire, readV2EnvelopeFromWire, getMeshV2DrainCounters, isMeshProtocolV2EnforceEnabled, } from './mesh-events-pending.js';
3
3
  export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
4
- export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
4
+ export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, getMeshV2BackstopCounters, } from './mesh-reconcile-loop.js';
5
5
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
6
6
  export { tryAssignQueueTask, isSessionActivelyGenerating, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
@@ -1,4 +1,16 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ declare const meshV2BackstopCounters: {
3
+ /** PHASE-4 transcript synthesis actually reconciled a missing completion. */
4
+ phase4SynthesisFired: number;
5
+ /** Acked-hold transcript fast-track promoted a synth ahead of the death deadline. */
6
+ ackedHoldFastTrackFired: number;
7
+ /** Acked-hold death-deadline backstop released a held synth (the 8-min net). */
8
+ ackedHoldDeathDeadlineFired: number;
9
+ };
10
+ /** Test/observability accessor for the v2 last-resort backstop counters (snapshot). */
11
+ export declare function getMeshV2BackstopCounters(): Readonly<typeof meshV2BackstopCounters>;
12
+ /** Test helper: zero the backstop counters so a case starts from a clean slate. */
13
+ export declare function __resetMeshV2BackstopCountersForTests(): void;
2
14
  export declare function __resetReconcileInFlightSynthDebounceForTests(): void;
3
15
  /**
4
16
  * DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
@@ -1,4 +1,5 @@
1
1
  import type { ProviderModule } from './contracts.js';
2
+ export declare function normalizeApprovalLabel(value: string): string;
2
3
  /**
3
4
  * True when any of the given button labels reads as a decline/negative option
4
5
  * (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
@@ -283,6 +283,15 @@ export declare class CliProviderInstance implements ProviderInstance {
283
283
  private completionHasFinalAssistantMessage;
284
284
  private buildExternalTranscriptProbe;
285
285
  private recordPendingTranscriptProbe;
286
+ /**
287
+ * The spawned CLI's env overrides (e.g. the mesh coordinator points hermes
288
+ * at a per-coordinator HERMES_HOME so its state.db lives in a tmpdir instead
289
+ * of ~/.hermes). The native-history executor expands `${HERMES_HOME:-~/.hermes}`
290
+ * from this map, so the completion gate MUST pass it through — otherwise the
291
+ * gate reads ~/.hermes, finds no coordinator-session transcript, and
292
+ * false-fires missing_final_assistant on every coordinator turn.
293
+ */
294
+ private spawnedEnvOverrides;
286
295
  private readExternalCompletionMessages;
287
296
  private completionFinalAssistantEvidence;
288
297
  private completionFinalSummary;
@@ -83,6 +83,28 @@ export interface NativeHistorySqliteSource {
83
83
  path: string;
84
84
  session_query: string;
85
85
  message_query: string;
86
+ /**
87
+ * Optional sub-session cluster expansion. Some agents (hermes ≥0.14) split
88
+ * a SINGLE logical turn across several `sessions` rows linked by a parent
89
+ * pointer, and the turn's final assistant message lands in a DIFFERENT row
90
+ * than the one `session_query` / the daemon's pin resolves. Reading only
91
+ * the anchor session then surfaces zero (or stale) assistant bubbles even
92
+ * though the answer is physically present in a sibling/descendant row —
93
+ * `read_chat` returns no final assistant and the completion gate false-fires
94
+ * `missing_final_assistant`.
95
+ *
96
+ * When present, the executor treats the resolved session id as an ANCHOR
97
+ * and runs this query (bound `?` = anchor id) to expand it to every session
98
+ * id in the same logical cluster (typically a `WITH RECURSIVE` walk over the
99
+ * parent pointer, up to the root and back down through all descendants).
100
+ * `message_query` is then run once per cluster id and the rows merged and
101
+ * re-sorted by their mapped timestamp, so the turn's final assistant — in
102
+ * whichever sub-session it was written — is always included. Each returned
103
+ * row's first column is a cluster session id.
104
+ *
105
+ * Absent → single-session behaviour is unchanged (anchor session only).
106
+ */
107
+ session_cluster_query?: string;
86
108
  message_map: NativeHistoryMessageMap;
87
109
  }
88
110
  export interface NativeHistoryMessageMap {
@@ -743,6 +743,37 @@ export interface RepoMeshStatus {
743
743
  * Omitted when nothing was drained. Mirrors the MCP tool's meshProtocolMetrics.
744
744
  */
745
745
  meshProtocolMetrics?: MeshProtocolMetrics;
746
+ /**
747
+ * T6 (B3c): live process-lifetime mesh-protocol-v2 enforce counters from THIS
748
+ * daemon — the enforce flag state, drain-routing tallies (deliver / route-away /
749
+ * dedup / quarantine), and the last-resort backstop fire counts (PHASE-4 synth,
750
+ * acked-hold fast-track / death-deadline). Diagnostic-only and never cached (a
751
+ * live snapshot). Under enforce, non-zero quarantine or backstop counts are the
752
+ * rollout-health signal (target 0). Omitted when unavailable.
753
+ */
754
+ meshProtocolV2Counters?: MeshProtocolV2Counters;
755
+ }
756
+ /** T6 (B3c) live v2 enforce/observability counters (see RepoMeshStatus.meshProtocolV2Counters). */
757
+ export interface MeshProtocolV2Counters {
758
+ /** True when MESH_PROTOCOL_V2_ENFORCE is active on this daemon. */
759
+ enforce: boolean;
760
+ /** Drain-path routing tallies (accept + enforce). Process-lifetime totals. */
761
+ drain: {
762
+ v2Delivered: number;
763
+ v2RoutedAway: number;
764
+ v2DedupSkipped: number;
765
+ v2ValidationFailedAccepted: number;
766
+ v2ReattributedToDrainer: number;
767
+ v1BroadcastAccepted: number;
768
+ v2ValidationFailedQuarantined: number;
769
+ v1UnversionedQuarantined: number;
770
+ };
771
+ /** Last-resort backstop fire counts. Target 0 under a healthy v2 contract. */
772
+ backstop: {
773
+ phase4SynthesisFired: number;
774
+ ackedHoldFastTrackFired: number;
775
+ ackedHoldDeathDeadlineFired: number;
776
+ };
746
777
  }
747
778
  /** One provider's version skew across mesh nodes (see RepoMeshStatus.providerVersionSkew). */
748
779
  export interface MeshProviderVersionSkew {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.461",
3
+ "version": "0.9.82-rc.463",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.461",
51
- "@adhdev/session-host-core": "0.9.82-rc.461",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.463",
51
+ "@adhdev/session-host-core": "0.9.82-rc.463",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -12,6 +12,9 @@ import {
12
12
  drainPendingMeshCoordinatorEvents,
13
13
  shouldHoldPendingDrainForBusyLocalCoordinator,
14
14
  resolveCoordinatorDrainDeliverability,
15
+ getMeshV2DrainCounters,
16
+ getMeshV2BackstopCounters,
17
+ isMeshProtocolV2EnforceEnabled,
15
18
  } from '../../mesh/mesh-events.js';
16
19
  import { normalizeInteractivePromptResponse } from '../../providers/types/interactive-prompt.js';
17
20
  import type { HighFamilyContext, HighFamilyHandler } from './types.js';
@@ -66,11 +69,21 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
66
69
  return { success: true, events: [], heldForBusyLocalCoordinator: true, hasLiveCliCoordinator };
67
70
  }
68
71
  const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
72
+ // T6 (B3c): ride the live v2 enforce/backstop counters on the drain response so a
73
+ // pure stdio MCP coordinator (which reads its inbox via this IPC call, not the
74
+ // daemon-core mesh_status command) sees the same enforce state + quarantine /
75
+ // last-resort-backstop tallies. Process-lifetime snapshot; the counters were just
76
+ // updated by the drain above. Additive — omitting it keeps version-skewed pullers safe.
77
+ const meshProtocolV2Counters = {
78
+ enforce: isMeshProtocolV2EnforceEnabled(),
79
+ drain: { ...getMeshV2DrainCounters() },
80
+ backstop: { ...getMeshV2BackstopCounters() },
81
+ };
69
82
  // SELF-COORDINATOR INBOX LEVEL-DRAIN: when the busy local coordinator drained its OWN
70
83
  // inbox (selfCoordinatorInboxRead), tell the puller these events were surfaced through
71
84
  // the caller's tool result — it must NOT re-forward them into the (busy) PTY (that is the
72
85
  // lossy path). Absent the flag, delivery is unchanged (reconcile-owned PTY / remote pull).
73
- return { success: true, events, hasLiveCliCoordinator, ...(selfCoordinatorInboxRead ? { surfacedForSelfCoordinator: true } : {}) };
86
+ return { success: true, events, hasLiveCliCoordinator, meshProtocolV2Counters, ...(selfCoordinatorInboxRead ? { surfacedForSelfCoordinator: true } : {}) };
74
87
  },
75
88
 
76
89
  interactive_prompt_response: async (ctx: HighFamilyContext, args: any) => {
@@ -19,7 +19,13 @@ import {
19
19
  normalizeMeshNodeId,
20
20
  daemonIdsEquivalent,
21
21
  } from '@adhdev/mesh-shared';
22
- import { getPendingMeshCoordinatorEvents } from '../../mesh/mesh-events.js';
22
+ import {
23
+ getPendingMeshCoordinatorEvents,
24
+ getMeshV2DrainCounters,
25
+ getMeshV2BackstopCounters,
26
+ isMeshProtocolV2EnforceEnabled,
27
+ } from '../../mesh/mesh-events.js';
28
+ import type { MeshProtocolV2Counters } from '../../repo-mesh-types.js';
23
29
  import { getRecentUnroutableDeliveries } from '../../mesh/mesh-routing.js';
24
30
  import { normalizeMeshDaemonRole, resolveMeshHostStatus } from '../../mesh/mesh-host-ownership.js';
25
31
  import { buildPreviewFreshness } from '../../mesh/preview-freshness.js';
@@ -592,6 +598,15 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
592
598
  // that a worker completion was lost (envelope present, mesh unresolved) instead
593
599
  // of it vanishing silently. Diagnostic-only — never cached (see omit below).
594
600
  const unroutableDeliveries = getRecentUnroutableDeliveries();
601
+ // T6 (B3c): live enforce/observability counters from this daemon. A
602
+ // process-lifetime snapshot (never cached — like unroutableDeliveries)
603
+ // so an operator/coordinator can read enforce state, quarantine tallies,
604
+ // and last-resort backstop fires straight from the aggregate status.
605
+ const meshProtocolV2Counters: MeshProtocolV2Counters = {
606
+ enforce: isMeshProtocolV2EnforceEnabled(),
607
+ drain: { ...getMeshV2DrainCounters() },
608
+ backstop: { ...getMeshV2BackstopCounters() },
609
+ };
595
610
  const previewFreshness = (() => {
596
611
  const localRepoRoot = nodeStatuses
597
612
  .map((node: any) => readStringValue(node?.git?.repoRoot, node?.repoRoot, node?.workspace))
@@ -707,6 +722,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
707
722
  ...(historicalSessions ? { historicalSessions } : {}),
708
723
  ...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),
709
724
  ...(unroutableDeliveries.length > 0 ? { unroutableDeliveries } : {}),
725
+ meshProtocolV2Counters,
710
726
  activeRefineJobs: Array.from(ctx.runningRefineJobs.values())
711
727
  .filter(job => job.meshId === meshId)
712
728
  .map(job => ({
@@ -718,7 +734,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
718
734
  targetCoordinatorDaemonId: job.targetCoordinatorDaemonId,
719
735
  })),
720
736
  };
721
- const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, ...cacheableStatusResult } = statusResult as any;
737
+ const { pendingCoordinatorEvents: _pendingCoordinatorEvents, unroutableDeliveries: _unroutableDeliveries, meshProtocolV2Counters: _meshProtocolV2Counters, ...cacheableStatusResult } = statusResult as any;
722
738
  // Verbose carries full mission goals; never store it in the shared
723
739
  // (compact) aggregate cache or a later compact poll would return the
724
740
  // heavy goals from cache. Return it without caching.
@@ -729,6 +745,7 @@ export const meshStatusHandlers: Record<string, HighFamilyHandler> = {
729
745
  ...rememberedStatus,
730
746
  ...(pendingCoordinatorEvents.length > 0 ? { pendingCoordinatorEvents } : {}),
731
747
  ...(unroutableDeliveries.length > 0 ? { unroutableDeliveries } : {}),
748
+ meshProtocolV2Counters,
732
749
  };
733
750
  logRepoMeshStatusDebug('return_live', {
734
751
  meshId,
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Aggregate mesh-status cache — extracted from router.ts (behavior-preserving code move).
3
+ *
4
+ * These functions were `DaemonCommandRouter` methods; they now take the router
5
+ * instance as `self`. The class keeps thin delegating wrappers
6
+ * (getCachedAggregateMeshStatus / rememberAggregateMeshStatus are bound into
7
+ * HighFamilyContext — intra-cluster calls therefore go through `self.` so instance
8
+ * dispatch is preserved). No log string, error message, refusal condition, or
9
+ * result shape changed — only physical location + `this.` → `self.`.
10
+ *
11
+ * The group is self-contained: it reads only `self.aggregateMeshStatusCache` and
12
+ * imported mesh-node-identity / mesh-work-queue helpers. It does NOT touch the
13
+ * inline-mesh cache cluster, so the move is a pure lift.
14
+ */
15
+ import type { DaemonCommandRouter } from './router.js';
16
+ import { normalizeMeshNodeId } from '@adhdev/mesh-shared';
17
+ import { getMeshQueueRevision } from '../mesh/mesh-work-queue.js';
18
+ import {
19
+ applyInlineMeshBranchConvergence,
20
+ buildInlineMeshTransitGitStatus,
21
+ buildLivePeerGitConnection,
22
+ deriveMeshNodeHealthFromGit,
23
+ isDeadLocalWorktreeNode,
24
+ readBooleanValue,
25
+ readInlineMeshNodeId,
26
+ readObjectRecord,
27
+ readStringValue,
28
+ shouldRefreshStalePendingAggregate,
29
+ summarizeInlineMeshBranchConvergence,
30
+ } from '../mesh/mesh-node-identity.js';
31
+
32
+ export function cloneJsonValue<T>(value: T): T {
33
+ if (typeof structuredClone === 'function') return structuredClone(value);
34
+ return JSON.parse(JSON.stringify(value)) as T;
35
+ }
36
+
37
+ export function hydrateCachedAggregateMeshStatusFromInline(
38
+ self: DaemonCommandRouter,
39
+ snapshot: any,
40
+ mesh: any,
41
+ options?: { requireDirectPeerTruth?: boolean },
42
+ ): any {
43
+ if (!mesh || typeof mesh !== 'object' || !Array.isArray(mesh.nodes) || !Array.isArray(snapshot?.nodes)) return snapshot;
44
+ const inlineNodesById = new Map<string, any>();
45
+ for (const node of mesh.nodes) {
46
+ const nodeId = readInlineMeshNodeId(node);
47
+ if (nodeId) inlineNodesById.set(nodeId, node);
48
+ }
49
+ if (!inlineNodesById.size) return snapshot;
50
+
51
+ let changed = false;
52
+ const unavailableNodeIds = new Set<string>();
53
+ const sourceOfTruth = readObjectRecord(snapshot.sourceOfTruth);
54
+ const directPeerTruth = readObjectRecord(sourceOfTruth.directPeerTruth);
55
+ // Dead local worktree nodes (isLocalWorktree, workspace deleted from disk)
56
+ // carry no live truth and must never gate the aggregate as unavailable.
57
+ // A cached snapshot built before the worktree was removed can still list
58
+ // such a node in unavailableNodeIds, which would wedge the graph in a
59
+ // permanent direct_peer_truth_unavailable; drop them here so the held
60
+ // standing-state truth for the surviving nodes satisfies the aggregate.
61
+ const deadNodeIds = new Set<string>();
62
+ for (const node of mesh.nodes) {
63
+ if (!isDeadLocalWorktreeNode(node)) continue;
64
+ const deadId = readInlineMeshNodeId(node);
65
+ if (deadId) deadNodeIds.add(deadId);
66
+ }
67
+ let droppedDeadUnavailable = false;
68
+ for (const entry of Array.isArray(directPeerTruth.unavailableNodeIds) ? directPeerTruth.unavailableNodeIds : []) {
69
+ const nodeId = readStringValue(entry);
70
+ if (!nodeId) continue;
71
+ if (deadNodeIds.has(nodeId)) {
72
+ droppedDeadUnavailable = true;
73
+ continue;
74
+ }
75
+ unavailableNodeIds.add(nodeId);
76
+ }
77
+ // Force a rewrite when a dead worktree was filtered out of a previously
78
+ // built unavailable set, even if no live git was re-hydrated this pass —
79
+ // otherwise the early-return below would hand back the stale snapshot that
80
+ // still says direct_peer_truth_unavailable.
81
+ if (droppedDeadUnavailable) changed = true;
82
+
83
+ const nodes = snapshot.nodes.map((statusNode: any) => {
84
+ const nodeId = normalizeMeshNodeId(statusNode);
85
+ const inlineNode = nodeId ? inlineNodesById.get(nodeId) : undefined;
86
+ if (!inlineNode) return statusNode;
87
+ const liveGit = buildInlineMeshTransitGitStatus(inlineNode);
88
+ if (!liveGit) return statusNode;
89
+ const nextStatus = { ...statusNode };
90
+ nextStatus.git = liveGit;
91
+ nextStatus.health = deriveMeshNodeHealthFromGit(liveGit);
92
+ applyInlineMeshBranchConvergence(mesh, inlineNode, nextStatus);
93
+ nextStatus.launchReady = readBooleanValue(nextStatus.launchReady) ?? true;
94
+ const connection = readObjectRecord(nextStatus.connection);
95
+ const connectionState = readStringValue(connection.state);
96
+ const connectionReported = readBooleanValue(connection.reported) ?? false;
97
+ if (!connectionReported || connectionState === 'unknown') {
98
+ nextStatus.connection = buildLivePeerGitConnection(connection);
99
+ }
100
+ delete nextStatus.gitProbePending;
101
+ const error = readStringValue(nextStatus.error);
102
+ if (error && /pending_git|git probe|live peer git snapshot|no peer git snapshot/i.test(error)) delete nextStatus.error;
103
+ if (!readStringValue(nextStatus.machineStatus)) nextStatus.machineStatus = 'online';
104
+ if (nodeId) unavailableNodeIds.delete(nodeId);
105
+ changed = true;
106
+ return nextStatus;
107
+ });
108
+
109
+ const aggregateDirectTruthSatisfied = sourceOfTruth.coordinatorOwnsLiveTruth === true
110
+ || directPeerTruth.satisfied === true;
111
+ if (!changed && !(options?.requireDirectPeerTruth && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied)) return snapshot;
112
+ const nextSourceOfTruth = {
113
+ ...sourceOfTruth,
114
+ ...(Object.keys(directPeerTruth).length ? {
115
+ directPeerTruth: {
116
+ ...directPeerTruth,
117
+ satisfied: options?.requireDirectPeerTruth === true
118
+ ? aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0
119
+ : directPeerTruth.satisfied,
120
+ unavailableNodeIds: [...unavailableNodeIds],
121
+ },
122
+ ...(options?.requireDirectPeerTruth === true ? {
123
+ coordinatorOwnsLiveTruth: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0,
124
+ currentStatus: aggregateDirectTruthSatisfied || unavailableNodeIds.size === 0 ? 'live_git_and_session_probes' : 'direct_peer_truth_unavailable',
125
+ } : {}),
126
+ } : {}),
127
+ };
128
+ return {
129
+ ...snapshot,
130
+ ...(options?.requireDirectPeerTruth === true && unavailableNodeIds.size > 0 && !aggregateDirectTruthSatisfied ? {
131
+ success: false,
132
+ code: 'mesh_direct_peer_truth_unavailable',
133
+ error: 'Selected coordinator could not confirm direct mesh truth for every remote node yet.',
134
+ } : {}),
135
+ sourceOfTruth: nextSourceOfTruth,
136
+ branchConvergenceSummary: summarizeInlineMeshBranchConvergence(nodes),
137
+ nodes,
138
+ };
139
+ }
140
+
141
+ export function getCachedAggregateMeshStatus(
142
+ self: DaemonCommandRouter,
143
+ meshId: string,
144
+ mesh?: any,
145
+ options?: { requireDirectPeerTruth?: boolean; allowStalePending?: boolean },
146
+ ): any | null {
147
+ const cached = self.aggregateMeshStatusCache.get(meshId);
148
+ if (!cached?.snapshot || cached.snapshot.success !== true || !Array.isArray(cached.snapshot.nodes)) return null;
149
+ // Genuine invalidation still forces truth: a queue mutation bumps the
150
+ // revision, so a stale-revision snapshot is never served (even under the
151
+ // SWR allowStalePending path below).
152
+ if (cached.queueRevision !== getMeshQueueRevision(meshId)) return null;
153
+ let snapshot = cloneJsonValue(cached.snapshot);
154
+ snapshot = hydrateCachedAggregateMeshStatusFromInline(self, snapshot, mesh, options);
155
+ // SWR: allowStalePending lets the interactive detail-open serve a snapshot
156
+ // that still has pending peer-git nodes (would otherwise miss here) so the
157
+ // graph paints instantly; the caller fires a background freshen. The
158
+ // queueRevision guard above is NOT relaxed — only the pending-git freshness
159
+ // gate is, so a genuine queue/identity mutation still forces a live rebuild.
160
+ if (!options?.allowStalePending && shouldRefreshStalePendingAggregate(snapshot, options)) return null;
161
+ const ageMs = Math.max(0, Date.now() - cached.builtAt);
162
+ const sourceOfTruth = snapshot.sourceOfTruth && typeof snapshot.sourceOfTruth === 'object'
163
+ ? snapshot.sourceOfTruth
164
+ : {};
165
+ snapshot.sourceOfTruth = {
166
+ ...sourceOfTruth,
167
+ aggregateSnapshot: {
168
+ ...(sourceOfTruth.aggregateSnapshot && typeof sourceOfTruth.aggregateSnapshot === 'object'
169
+ ? sourceOfTruth.aggregateSnapshot
170
+ : {}),
171
+ owner: 'coordinator_daemon_memory',
172
+ cached: true,
173
+ source: 'memory',
174
+ refreshReason: 'memory_cache_hit',
175
+ ageMs,
176
+ cachedAt: new Date(cached.builtAt).toISOString(),
177
+ returnedAt: new Date().toISOString(),
178
+ },
179
+ };
180
+ return snapshot;
181
+ }
182
+
183
+ export function rememberAggregateMeshStatus(
184
+ self: DaemonCommandRouter,
185
+ meshId: string,
186
+ snapshot: any,
187
+ refreshReason: string,
188
+ ): any {
189
+ if (!snapshot || typeof snapshot !== 'object' || snapshot.success !== true || !Array.isArray(snapshot.nodes)) return snapshot;
190
+ const builtAt = Date.now();
191
+ const next = cloneJsonValue(snapshot);
192
+ const sourceOfTruth = next.sourceOfTruth && typeof next.sourceOfTruth === 'object'
193
+ ? next.sourceOfTruth
194
+ : {};
195
+ next.sourceOfTruth = {
196
+ ...sourceOfTruth,
197
+ aggregateSnapshot: {
198
+ owner: 'coordinator_daemon_memory',
199
+ cached: false,
200
+ source: 'live_refresh',
201
+ refreshReason,
202
+ ageMs: 0,
203
+ cachedAt: new Date(builtAt).toISOString(),
204
+ returnedAt: new Date(builtAt).toISOString(),
205
+ },
206
+ };
207
+ self.aggregateMeshStatusCache.set(meshId, { builtAt, snapshot: cloneJsonValue(next), queueRevision: getMeshQueueRevision(meshId) });
208
+ return next;
209
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Remote mesh-session owner resolution — extracted from router.ts (behavior-preserving code move).
3
+ *
4
+ * These functions were `DaemonCommandRouter` methods; they now take the router
5
+ * instance as `self`. resolveRemoteMeshSessionOwnerDaemonId stays reachable as a
6
+ * public method (the [Z] session-scoped forward in executeDaemonCommand and the
7
+ * mesh-session-scoped-remote-forward test call it), so the class keeps a thin
8
+ * delegating wrapper. No log string, error message, refusal condition, or result
9
+ * shape changed — only physical location + `this.` → `self.`.
10
+ *
11
+ * The group is read-only: it reads self.deps.statusInstanceId, the cached inline-mesh
12
+ * nodes, and the aggregate-status snapshot nodes. It never mutates router state.
13
+ */
14
+ import type { DaemonCommandRouter } from './router.js';
15
+ import { meshNodeIdMatches, daemonIdsEquivalent } from '@adhdev/mesh-shared';
16
+ import {
17
+ collectMeshNodeHostedSessionIds,
18
+ readMeshNodeDaemonId,
19
+ readObjectRecord,
20
+ } from '../mesh/mesh-node-identity.js';
21
+
22
+ /**
23
+ * Resolve the REMOTE worker daemonId that owns a given session, when the session
24
+ * belongs to a mesh node hosted on a DIFFERENT daemon than this coordinator.
25
+ *
26
+ * The coordinator does not host remote-worker session instances in its own
27
+ * instanceManager/sessionRegistry — only their cached mesh-node metadata. A
28
+ * dashboard-issued session-scoped command (invoke_provider_script / resolve_action /
29
+ * set_mode / …) lands on the coordinator with a targetSessionId the coordinator can't
30
+ * find locally, and without forwarding it dies as "Live session not found". send_chat
31
+ * happens to survive (its target resolves to the worker by another route), but the
32
+ * controlbar commands do not — so the controlbar buttons appear to do nothing.
33
+ *
34
+ * Mirror the existing node-level remote-forward pattern (fast_forward_mesh_node etc.):
35
+ * scan the candidate mesh nodes for the one hosting the targetSessionId, and return its
36
+ * daemonId when that daemonId is a remote daemon (i.e. not this coordinator's own
37
+ * statusInstanceId). Returns undefined for a locally-hosted session (no forward — execute
38
+ * locally as before) or when ownership can't be resolved.
39
+ *
40
+ * The candidate set spans BOTH the cached inline-mesh nodes and the live aggregate
41
+ * mesh-status snapshots. The inline cache reliably carries only each node's single primary
42
+ * session (cachedStatus.activeSession), so a worker hosting more than one session exposes its
43
+ * non-primary sessions only on the aggregate snapshot nodes (status.activeSessions /
44
+ * activeSessionDetails, built from live session records). collectMeshNodeHostedSessionIds does
45
+ * the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
46
+ * session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
47
+ * other consumers depend on stay untouched.
48
+ *
49
+ * CANCEL-STOP-RELAY: the session-id cache scan above only matches when the coordinator's
50
+ * cached status snapshot already lists the worker's session id in a recognized active-sessions
51
+ * shape. A worktree-clone worker session whose id form/timing differs from the cached snapshot
52
+ * (or is simply not yet reflected) misses the scan, so a stop that carries the authoritative
53
+ * owning nodeId (mesh_queue_cancel knows assignedNodeId) used to silently fail to forward.
54
+ * `ownerNodeIdHint` adds a deterministic fallback: when the session-id scan misses, resolve the
55
+ * owner daemonId by matching the node by id (meshNodeIdMatches — same form-tolerant compare the
56
+ * rest of the router uses, no new raw compare). The same self-loopback guard applies to both
57
+ * paths, so a coordinator-hosted node is still never force-forwarded to a remote form of itself.
58
+ */
59
+ export function resolveRemoteMeshSessionOwnerDaemonId(
60
+ self: DaemonCommandRouter,
61
+ sessionId: string,
62
+ ownerNodeIdHint?: string,
63
+ ): string | undefined {
64
+ const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
65
+ const nodeHint = typeof ownerNodeIdHint === 'string' ? ownerNodeIdHint.trim() : '';
66
+ if (!trimmed && !nodeHint) return undefined;
67
+ const selfDaemonId = self.deps.statusInstanceId;
68
+ const candidates = collectMeshSessionOwnerCandidateNodes(self);
69
+ if (trimmed) {
70
+ for (const node of candidates) {
71
+ if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
72
+ const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
73
+ // A matching node with no readable daemonId can't be attributed — keep scanning
74
+ // the remaining candidates (e.g. the same session on an aggregate node that does
75
+ // carry the daemonId) rather than bailing on the whole resolution.
76
+ if (!nodeDaemonId) continue;
77
+ // Only forward to a genuinely remote daemon. When the owning node is this
78
+ // coordinator itself (locally hosted worker), fall through to local handling.
79
+ // id-form robust: the node daemonId and selfDaemonId may be stored in different
80
+ // forms of the same machine — a strict `===` would miss the self-match and forward
81
+ // a local session to a remote form of THIS daemon (loopback).
82
+ if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
83
+ return nodeDaemonId;
84
+ }
85
+ }
86
+ // Deterministic fallback: the session-id scan missed (cache lag / id-form mismatch on a
87
+ // worktree-clone worker), but the caller knows the authoritative owning nodeId. Resolve the
88
+ // owner daemonId straight off that node — never the fuzzy session cache.
89
+ if (nodeHint) {
90
+ for (const node of candidates) {
91
+ if (!meshNodeIdMatches(node, nodeHint)) continue;
92
+ const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
93
+ if (!nodeDaemonId) continue;
94
+ if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
95
+ return nodeDaemonId;
96
+ }
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * Candidate nodes for remote-session owner resolution: the cached inline-mesh nodes (which
103
+ * carry each node's primary session) plus the nodes from every cached aggregate mesh-status
104
+ * snapshot (which carry each node's full live session list). getCachedInlineMeshNodes()
105
+ * returns a fresh array, so appending the aggregate nodes never mutates cached state.
106
+ */
107
+ export function collectMeshSessionOwnerCandidateNodes(self: DaemonCommandRouter): any[] {
108
+ const nodes: any[] = self.getCachedInlineMeshNodes();
109
+ for (const cached of self.aggregateMeshStatusCache.values()) {
110
+ const snapshotNodes = cached?.snapshot?.nodes;
111
+ if (Array.isArray(snapshotNodes)) nodes.push(...snapshotNodes);
112
+ }
113
+ return nodes;
114
+ }