@adhdev/daemon-core 0.9.82-rc.273 → 0.9.82-rc.274

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.
@@ -0,0 +1,256 @@
1
+ // ---------------------------------------------------------------------------
2
+ // mesh-reconcile-loop — periodic queue → live coordinator reconciliation
3
+ // ---------------------------------------------------------------------------
4
+ // Single-model replacement for the old event-based "spontaneous forward" paths
5
+ // (remote P2P mesh_forward_event dispatch + live-CLI PTY fire-and-forget inject).
6
+ // Those pushed events at the moment a worker transitioned state, and silently
7
+ // dropped on the network (P2P) or when the coordinator was generating.
8
+ //
9
+ // The reliable backbone has always been the pending-events queue (SQLite +
10
+ // JSONL): every mesh coordinator event is persisted there before anything else
11
+ // (see injectMeshSystemMessage). What was missing was an *active* drainer that
12
+ // runs on a schedule rather than only when the coordinator (an LLM) happens to
13
+ // call a mesh tool.
14
+ //
15
+ // This loop is that drainer. On a fixed interval it:
16
+ // 1. Finds live CLI coordinator sessions on THIS daemon (meshCoordinatorFor
17
+ // stamp). For each, drains the local queue scoped to this daemon and
18
+ // injects pending events into the coordinator when it is idle. (idle-only:
19
+ // a generating coordinator's PTY ignores send_message, so we leave events
20
+ // queued and retry next tick.)
21
+ // 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
22
+ // node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
23
+ // handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
24
+ // already does, now driven by the daemon timer instead of an LLM tool call.
25
+ //
26
+ // IMPORTANT — limits of this loop:
27
+ // - It only delivers to *live CLI coordinator instances* on this daemon. A
28
+ // pure stdio MCP coordinator (an LLM with no live CLI session to inject
29
+ // into) has no inject target here; that case stays pull-driven — the LLM
30
+ // drains the queue when it calls mesh_status / mesh_read_chat. We do NOT try
31
+ // to "wake" an LLM from the daemon; that is structurally impossible over a
32
+ // stdio request/response transport. See docs/refactoring/2026-06-15-mesh-event-to-queue-polling.md §4.7.
33
+ // - Queue persistence (queuePendingMeshCoordinatorEvent) and the SQLite
34
+ // drained=1 idempotency are the trust backbone and are untouched by this loop.
35
+ // ---------------------------------------------------------------------------
36
+
37
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
38
+ import { loadConfig } from '../config/config.js';
39
+ import { listMeshes } from '../config/mesh-config.js';
40
+ import { LOG } from '../logging/logger.js';
41
+ import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
42
+ import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
43
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
44
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent } from './mesh-events-coordinator.js';
45
+ import { readNonEmptyString } from './mesh-events-utils.js';
46
+
47
+ // Default reconcile cadence. approval/completion notifications to a live CLI
48
+ // coordinator land within at most one interval. Overridable via env for tuning.
49
+ const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
50
+
51
+ function resolveReconcileIntervalMs(): number {
52
+ const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
53
+ if (raw) {
54
+ const parsed = Number.parseInt(raw, 10);
55
+ if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
56
+ }
57
+ return DEFAULT_RECONCILE_INTERVAL_MS;
58
+ }
59
+
60
+ interface LiveCoordinator {
61
+ meshId: string;
62
+ instance: ReturnType<DaemonComponents['instanceManager']['getInstance']>;
63
+ idle: boolean;
64
+ }
65
+
66
+ // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
67
+ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
68
+ const out: LiveCoordinator[] = [];
69
+ for (const inst of components.instanceManager.getByCategory('cli')) {
70
+ const state = inst.getState();
71
+ const settings = state.settings && typeof state.settings === 'object'
72
+ ? state.settings as Record<string, unknown>
73
+ : {};
74
+ const meshId = readNonEmptyString(settings.meshCoordinatorFor);
75
+ if (!meshId) continue;
76
+ const status = readNonEmptyString(state.status).toLowerCase();
77
+ out.push({ meshId, instance: inst, idle: status === 'idle' });
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // Inject a drained pending event into a live, idle coordinator session.
83
+ function injectPendingIntoCoordinator(
84
+ coordinator: LiveCoordinator['instance'],
85
+ pending: PendingMeshCoordinatorEvent,
86
+ ): void {
87
+ if (!coordinator || !pending.coordinatorMessage) return;
88
+ const force = shouldForceInjectMeshEvent(pending.event);
89
+ coordinator.onEvent('send_message', {
90
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
91
+ ...(force ? { force: true } : {}),
92
+ });
93
+ }
94
+
95
+ // One reconcile tick across every mesh that has a live CLI coordinator here.
96
+ export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
97
+ const coordinators = findLiveCoordinators(components);
98
+ if (coordinators.length === 0) {
99
+ // No live CLI coordinator on this daemon — nothing to inject into.
100
+ // (MCP-only LLM coordinators drain the queue via their own tool calls.)
101
+ return;
102
+ }
103
+
104
+ // Group coordinators by mesh; multiple coordinator instances for one mesh is
105
+ // unusual but supported (each gets the same drained events).
106
+ const byMesh = new Map<string, LiveCoordinator[]>();
107
+ for (const c of coordinators) {
108
+ const list = byMesh.get(c.meshId);
109
+ if (list) list.push(c);
110
+ else byMesh.set(c.meshId, [c]);
111
+ }
112
+
113
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
114
+ const dispatchMeshCommand = components.dispatchMeshCommand;
115
+ const store = (() => {
116
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
117
+ })();
118
+
119
+ for (const [meshId, meshCoordinators] of byMesh) {
120
+ // (a) Cloud-only: pull remote worker node daemons' queues over P2P and
121
+ // re-inject locally. This is the same cross-daemon pull the MCP
122
+ // drainCoordinatorPendingEvents performs, lifted to the daemon timer.
123
+ // On standalone (no dispatchMeshCommand) this whole block is skipped,
124
+ // keeping cloud/standalone identical for the local case.
125
+ if (dispatchMeshCommand) {
126
+ try {
127
+ await pullRemoteNodeQueues(components, meshId, localDaemonId);
128
+ } catch (e: any) {
129
+ LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${meshId}: ${e?.message || e}`);
130
+ }
131
+ }
132
+
133
+ // (b) Drain the local queue scoped to this coordinator daemon and inject
134
+ // into idle coordinators. A generating coordinator is skipped — its
135
+ // events stay queued (drained=1 only happens inside the drain call,
136
+ // so we must NOT drain when there is no idle coordinator to receive).
137
+ const idleCoordinators = meshCoordinators.filter(c => c.idle);
138
+ if (idleCoordinators.length === 0) continue;
139
+
140
+ // O(1) guard: skip the drain entirely when the queue is empty.
141
+ if (store) {
142
+ try {
143
+ if (store.pendingEventCount(meshId) === 0) continue;
144
+ } catch { /* fall through to drain */ }
145
+ }
146
+
147
+ let pendingEvents: PendingMeshCoordinatorEvent[] = [];
148
+ try {
149
+ pendingEvents = drainPendingMeshCoordinatorEvents(meshId, localDaemonId);
150
+ } catch (e: any) {
151
+ LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
152
+ continue;
153
+ }
154
+ if (pendingEvents.length === 0) continue;
155
+
156
+ LOG.info('MeshReconcile', `Reconcile inject: ${pendingEvents.length} pending event(s) → ${idleCoordinators.length} idle coordinator(s) for mesh ${meshId}`);
157
+ for (const pending of pendingEvents) {
158
+ for (const c of idleCoordinators) {
159
+ injectPendingIntoCoordinator(c.instance, pending);
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ // Cloud-only: poll each remote worker node daemon for pending coordinator events
166
+ // and re-inject them locally via handleMeshForwardEvent (which re-queues +
167
+ // surfaces to the live coordinator on the next tick / immediately if idle).
168
+ async function pullRemoteNodeQueues(
169
+ components: DaemonComponents,
170
+ meshId: string,
171
+ localDaemonId: string | undefined,
172
+ ): Promise<void> {
173
+ const dispatchMeshCommand = components.dispatchMeshCommand;
174
+ if (!dispatchMeshCommand) return;
175
+ const mesh = listMeshes().find(m => m.id === meshId);
176
+ if (!mesh) return;
177
+
178
+ const pendingEventArgs: Record<string, unknown> = {
179
+ meshId,
180
+ ...(localDaemonId ? { coordinatorDaemonId: localDaemonId } : {}),
181
+ };
182
+
183
+ for (const node of mesh.nodes) {
184
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
185
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
186
+ // already in the local queue drained in step (b)).
187
+ if (!nodeDaemonId) continue;
188
+ if (localDaemonId && nodeDaemonId === localDaemonId) continue;
189
+
190
+ let events: unknown;
191
+ try {
192
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
193
+ } catch {
194
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
195
+ continue;
196
+ }
197
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
198
+ for (const event of list) {
199
+ const payload = buildForwardPayloadFromPending(event);
200
+ if (!payload.event || !payload.meshId) continue;
201
+ try {
202
+ handleMeshForwardEvent(components, payload);
203
+ } catch { /* best-effort re-inject */ }
204
+ }
205
+ }
206
+ }
207
+
208
+ function extractPendingEvents(raw: unknown): any[] {
209
+ if (Array.isArray(raw)) return raw;
210
+ if (raw && typeof raw === 'object') {
211
+ const events = (raw as Record<string, unknown>).events;
212
+ if (Array.isArray(events)) return events;
213
+ }
214
+ return [];
215
+ }
216
+
217
+ // Flatten a queued PendingMeshCoordinatorEvent into the flat payload shape
218
+ // handleMeshForwardEvent expects (mirrors the MCP buildMeshForwardPayloadFromPendingEvent).
219
+ function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
220
+ const metadata = event?.metadataEvent && typeof event.metadataEvent === 'object'
221
+ ? event.metadataEvent as Record<string, unknown>
222
+ : {};
223
+ return {
224
+ event: readNonEmptyString(event?.event),
225
+ meshId: readNonEmptyString(event?.meshId),
226
+ nodeId: readNonEmptyString(event?.nodeId) || readNonEmptyString(metadata.meshNodeId),
227
+ workspace: readNonEmptyString(event?.workspace) || readNonEmptyString(metadata.workspace),
228
+ ...metadata,
229
+ };
230
+ }
231
+
232
+ interface ReconcileLoopHandle {
233
+ stop(): void;
234
+ }
235
+
236
+ // Start the periodic reconcile loop. Returns a handle with stop() for shutdown.
237
+ export function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle {
238
+ const intervalMs = resolveReconcileIntervalMs();
239
+ let running = false;
240
+ const timer = setInterval(() => {
241
+ if (running) return; // never overlap ticks
242
+ running = true;
243
+ void runMeshReconcileTick(components)
244
+ .catch((e: any) => LOG.warn('MeshReconcile', `Reconcile tick error: ${e?.message || e}`))
245
+ .finally(() => { running = false; });
246
+ }, intervalMs);
247
+ // Don't keep the process alive solely for this timer.
248
+ if (typeof timer.unref === 'function') timer.unref();
249
+ LOG.info('MeshReconcile', `Mesh reconcile loop started (interval ${intervalMs}ms)`);
250
+ return {
251
+ stop() {
252
+ clearInterval(timer);
253
+ LOG.info('MeshReconcile', 'Mesh reconcile loop stopped');
254
+ },
255
+ };
256
+ }