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

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.
@@ -4,7 +4,7 @@ import { randomUUID } from 'crypto';
4
4
  import { LOG } from '../logging/logger.js';
5
5
  import { getLedgerDir, readLedgerEntries } from './mesh-ledger.js';
6
6
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
7
- import { buildMeshSystemMessage, canonicalDaemonId, readNonEmptyString, readRecord, resolveEventSessionId } from './mesh-events-utils.js';
7
+ import { buildMeshSystemMessage, readNonEmptyString, readRecord, resolveEventSessionId } from './mesh-events-utils.js';
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
@@ -83,46 +83,11 @@ export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent)
83
83
  ].join('::');
84
84
  }
85
85
 
86
- // R3: TTL for the direct-delivered marker. A coordinator polls get_pending_mesh_events
87
- // well within this window after a terminal event; after it expires the marker is swept and
88
- // a late/duplicate drain would (harmlessly) re-surface but by then the event is long gone
89
- // from the queue too. 10 minutes mirrors the completion-fingerprint TTL.
90
- const DIRECT_DELIVERED_TTL_MS = 10 * 60 * 1000;
91
-
92
- /**
93
- * R3: record that an event was direct-injected into a live coordinator on `coordinatorDaemonId`.
94
- * That coordinator's own drain (get_pending_mesh_events with the same coordinatorDaemonId) will
95
- * skip the queued copy, so it receives the event exactly once instead of twice (PTY + poll).
96
- * Other consumers (unscoped drainers, other daemons) are unaffected — they did not get the inject.
97
- */
98
- export function markMeshCoordinatorEventDirectDelivered(
99
- coordinatorDaemonId: string,
100
- event: PendingMeshCoordinatorEvent,
101
- ): void {
102
- // Canonicalize so the raw machineId used at inject time and the prefixed instanceId the
103
- // coordinator drains with resolve to the same key.
104
- const canonical = canonicalDaemonId(coordinatorDaemonId);
105
- if (!canonical) return;
106
- const fingerprint = buildPendingEventFingerprint(event);
107
- if (!fingerprint.trim()) return;
108
- try {
109
- const store = MeshRuntimeStore.getInstance();
110
- store.recordDirectDelivered(canonical, fingerprint, DIRECT_DELIVERED_TTL_MS);
111
- store.sweepExpiredDirectDelivered();
112
- } catch { /* best-effort — a duplicate is preferable to a crash */ }
113
- }
114
-
115
- function wasDirectDeliveredToCoordinator(coordinatorDaemonId: string, event: PendingMeshCoordinatorEvent): boolean {
116
- const canonical = canonicalDaemonId(coordinatorDaemonId);
117
- if (!canonical) return false;
118
- const fingerprint = buildPendingEventFingerprint(event);
119
- if (!fingerprint.trim()) return false;
120
- try {
121
- return MeshRuntimeStore.getInstance().wasDirectDelivered(canonical, fingerprint);
122
- } catch {
123
- return false;
124
- }
125
- }
86
+ // NOTE: the former R3 "direct-delivered" marker (markMeshCoordinatorEventDirectDelivered /
87
+ // wasDirectDeliveredToCoordinator) was removed when spontaneous PTY direct-inject was retired.
88
+ // Delivery is now queue-drain-only: an event is consumed by exactly one drainer via the atomic
89
+ // SQLite drained=1 marking, so there is no PTY-vs-poll double-delivery left to dedup against.
90
+ // The dormant mesh_direct_delivered_events table / store helpers remain (harmless) but unused.
126
91
 
127
92
  export function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean {
128
93
  const fingerprint = buildPendingEventFingerprint(event);
@@ -314,10 +279,77 @@ function atomicDrainFile(path: string): string | null {
314
279
  }
315
280
  }
316
281
 
317
- /** Drain and return all pending coordinator events for meshId, removing them from disk. */
318
- export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[] {
282
+ // Selectively drain a JSONL pending-events file: atomically claim it (rename), then
283
+ // consume only the lines whose parsed event matches `predicate` and rewrite the
284
+ // remaining (kept) lines back to the original path. Unparseable lines are kept
285
+ // untouched. Returns the consumed events. The rename makes claiming exclusive —
286
+ // only one concurrent caller wins, so there is no double-consume of the same lines.
287
+ function selectiveDrainFile(
288
+ path: string,
289
+ predicate: (event: PendingMeshCoordinatorEvent) => boolean,
290
+ ): PendingMeshCoordinatorEvent[] {
291
+ const tmpPath = `${path}.draining`;
292
+ try {
293
+ renameSync(path, tmpPath);
294
+ } catch {
295
+ return []; // another drain claimed it, or the file doesn't exist
296
+ }
297
+ let content: string;
298
+ try {
299
+ content = readFileSync(tmpPath, 'utf-8');
300
+ } catch {
301
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
302
+ return [];
303
+ }
304
+
305
+ const consumed: PendingMeshCoordinatorEvent[] = [];
306
+ const keptLines: string[] = [];
307
+ for (const line of content.split('\n')) {
308
+ if (!line) continue;
309
+ let parsed: PendingMeshCoordinatorEvent | undefined;
310
+ try { parsed = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { parsed = undefined; }
311
+ if (parsed && predicate(parsed)) {
312
+ consumed.push(parsed);
313
+ } else {
314
+ keptLines.push(line); // non-matching or unparseable → leave queued
315
+ }
316
+ }
317
+
318
+ try {
319
+ if (keptLines.length > 0) {
320
+ writeFileSync(path, keptLines.join('\n') + '\n', 'utf-8');
321
+ }
322
+ unlinkSync(tmpPath);
323
+ } catch {
324
+ // If the rewrite/cleanup fails, restore the claimed file so no events are
325
+ // lost — the next drain retries the whole file.
326
+ try { if (existsSync(tmpPath) && !existsSync(path)) renameSync(tmpPath, path); } catch { /* best-effort */ }
327
+ return [];
328
+ }
329
+ return consumed;
330
+ }
331
+
332
+ /**
333
+ * Drain and return pending coordinator events for meshId, removing the drained
334
+ * ones from both the SQLite inbox and the JSONL legacy file.
335
+ *
336
+ * When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
337
+ * drained; every other event stays queued (undrained in SQLite, rewritten back to
338
+ * the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
339
+ * events into a *generating* coordinator while leaving non-force progress events for
340
+ * the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
341
+ * atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
342
+ */
343
+ export function drainPendingMeshCoordinatorEvents(
344
+ meshId?: string,
345
+ coordinatorDaemonId?: string,
346
+ opts?: { onlyEvents?: ReadonlySet<string> },
347
+ ): PendingMeshCoordinatorEvent[] {
319
348
  if (!meshId) return [];
320
349
 
350
+ const onlyEvents = opts?.onlyEvents;
351
+ const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
352
+
321
353
  // Dual-write means SQLite and JSONL hold the same events. Both stores must be
322
354
  // emptied in one drain call — draining only one leaves the other to re-deliver
323
355
  // the same events on the next call. Merge with fingerprint dedup.
@@ -336,7 +368,7 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
336
368
  try {
337
369
  const store = MeshRuntimeStore.getInstance();
338
370
  if (store.pendingEventCount(meshId) > 0) {
339
- for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId)) {
371
+ for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId, onlyEvents ? { onlyEvents } : undefined)) {
340
372
  const event = row.payload as PendingMeshCoordinatorEvent;
341
373
  if (event) pushUnique(event);
342
374
  }
@@ -350,26 +382,34 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
350
382
  ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
351
383
  : [getPendingEventsPath(meshId)];
352
384
  for (const path of paths) {
385
+ const isSharedFile = coordinatorDaemonId && path === getPendingEventsPath(meshId);
386
+ // Targeting predicate for the shared (unscoped) file: only this coordinator's
387
+ // events (or legacy untargeted ones) are eligible.
388
+ const targets = (e: PendingMeshCoordinatorEvent): boolean =>
389
+ !isSharedFile || !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId;
390
+
391
+ if (onlyEvents) {
392
+ // Selective JSONL drain: consume only matching events, rewrite the rest back.
393
+ for (const event of selectiveDrainFile(path, e => targets(e) && matchesFilter(e.event))) {
394
+ pushUnique(event);
395
+ }
396
+ continue;
397
+ }
353
398
  const content = atomicDrainFile(path);
354
399
  if (!content) continue;
355
400
  const parsed = content.split('\n').filter(Boolean).flatMap(line => {
356
401
  try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
357
402
  });
358
403
  // If reading the shared file, filter to events that target this coordinator or are unscoped.
359
- const filtered = (coordinatorDaemonId && path === getPendingEventsPath(meshId))
360
- ? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
361
- : parsed;
404
+ const filtered = isSharedFile ? parsed.filter(targets) : parsed;
362
405
  for (const event of filtered) pushUnique(event);
363
406
  }
364
407
  if (merged.length === 0) return [];
365
- // R3: when this drain is scoped to a coordinator daemon, exclude events that were already
366
- // direct-injected into that coordinator's live CLI session. Unscoped drains (no daemon id)
367
- // keep everything they belong to consumers that never received the direct inject.
368
- const deliverable = coordinatorDaemonId
369
- ? merged.filter(event => !wasDirectDeliveredToCoordinator(coordinatorDaemonId, event))
370
- : merged;
371
- if (deliverable.length === 0) return [];
372
- return reconcilePendingMeshCoordinatorEvents(meshId, deliverable);
408
+ // (Former R3 direct-delivered dedup removed.) Spontaneous PTY direct-inject no
409
+ // longer exists delivery is now queue-drain-only (reconcile loop or MCP pull),
410
+ // so an event is consumed by exactly one drainer via the atomic SQLite drained=1
411
+ // marking. There is no PTY-vs-poll double path left to dedup against.
412
+ return reconcilePendingMeshCoordinatorEvents(meshId, merged);
373
413
  }
374
414
 
375
415
  /** Peek at pending coordinator events without draining (non-destructive). */
@@ -404,13 +444,9 @@ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaem
404
444
  pushUnique(event);
405
445
  }
406
446
 
407
- // R3: hide events already direct-delivered to this coordinator from its status peek, so
408
- // mesh_status doesn't report a "pending" event the coordinator has in fact already received.
409
- const deliverable = coordinatorDaemonId
410
- ? merged.filter(event => !wasDirectDeliveredToCoordinator(coordinatorDaemonId, event))
411
- : merged;
412
-
413
- return reconcilePendingMeshCoordinatorEvents(meshId, deliverable);
447
+ // (Former R3 direct-delivered filter removed no PTY direct-inject path exists
448
+ // anymore, so a peeked pending event has genuinely not yet been consumed.)
449
+ return reconcilePendingMeshCoordinatorEvents(meshId, merged);
414
450
  }
415
451
 
416
452
  /**
@@ -11,13 +11,17 @@ export {
11
11
  drainPendingMeshCoordinatorEvents,
12
12
  getPendingMeshCoordinatorEvents,
13
13
  clearPendingMeshCoordinatorEvents,
14
- markMeshCoordinatorEventDirectDelivered,
15
14
  } from './mesh-events-pending.js';
16
15
 
17
16
  export {
18
17
  reconcileDirectDispatchCompletionFromTranscript,
19
18
  } from './mesh-events-stale.js';
20
19
 
20
+ export {
21
+ setupMeshReconcileLoop,
22
+ runMeshReconcileTick,
23
+ } from './mesh-reconcile-loop.js';
24
+
21
25
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
22
26
  export {
23
27
  tryAssignQueueTask,
@@ -0,0 +1,276 @@
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 mesh, drains the local queue scoped to this daemon and
18
+ // injects pending events into the coordinator. When a coordinator is idle it
19
+ // receives every queued event. When ONLY generating coordinators exist (the
20
+ // common case while the coordinator is blocked awaiting a worker result), the
21
+ // loop force-drains ONLY the force-inject events (completion / approval / stop /
22
+ // refine·bootstrap terminal) and force-writes them into the generating PTY —
23
+ // the same busy-bypass send-guard escape the live-CLI inject used to use.
24
+ // Non-force progress events stay queued for the next idle tick (injecting them
25
+ // mid-generation would be noise). This is what makes a coordinator parked in
26
+ // `generating` while awaiting a worker's completion actually receive it.
27
+ // 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
28
+ // node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
29
+ // handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
30
+ // already does, now driven by the daemon timer instead of an LLM tool call.
31
+ //
32
+ // IMPORTANT — limits of this loop:
33
+ // - It only delivers to *live CLI coordinator instances* on this daemon. A
34
+ // pure stdio MCP coordinator (an LLM with no live CLI session to inject
35
+ // into) has no inject target here; that case stays pull-driven — the LLM
36
+ // drains the queue when it calls mesh_status / mesh_read_chat. We do NOT try
37
+ // to "wake" an LLM from the daemon; that is structurally impossible over a
38
+ // stdio request/response transport. See docs/refactoring/2026-06-15-mesh-event-to-queue-polling.md §4.7.
39
+ // - Queue persistence (queuePendingMeshCoordinatorEvent) and the SQLite
40
+ // drained=1 idempotency are the trust backbone and are untouched by this loop.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
44
+ import { loadConfig } from '../config/config.js';
45
+ import { listMeshes } from '../config/mesh-config.js';
46
+ import { LOG } from '../logging/logger.js';
47
+ import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
48
+ import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
49
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
50
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
51
+ import { readNonEmptyString } from './mesh-events-utils.js';
52
+
53
+ // Default reconcile cadence. approval/completion notifications to a live CLI
54
+ // coordinator land within at most one interval. Overridable via env for tuning.
55
+ const DEFAULT_RECONCILE_INTERVAL_MS = 4_000;
56
+
57
+ function resolveReconcileIntervalMs(): number {
58
+ const raw = readNonEmptyString(process.env.MESH_RECONCILE_INTERVAL_MS);
59
+ if (raw) {
60
+ const parsed = Number.parseInt(raw, 10);
61
+ if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 60_000) return parsed;
62
+ }
63
+ return DEFAULT_RECONCILE_INTERVAL_MS;
64
+ }
65
+
66
+ interface LiveCoordinator {
67
+ meshId: string;
68
+ instance: ReturnType<DaemonComponents['instanceManager']['getInstance']>;
69
+ idle: boolean;
70
+ }
71
+
72
+ // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
73
+ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
74
+ const out: LiveCoordinator[] = [];
75
+ for (const inst of components.instanceManager.getByCategory('cli')) {
76
+ const state = inst.getState();
77
+ const settings = state.settings && typeof state.settings === 'object'
78
+ ? state.settings as Record<string, unknown>
79
+ : {};
80
+ const meshId = readNonEmptyString(settings.meshCoordinatorFor);
81
+ if (!meshId) continue;
82
+ const status = readNonEmptyString(state.status).toLowerCase();
83
+ out.push({ meshId, instance: inst, idle: status === 'idle' });
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // Inject a drained pending event into a live coordinator session. Force-inject
89
+ // events carry force:true so they bypass the busy send-guard and land in the PTY
90
+ // even while the coordinator is generating (see shouldForceInjectMeshEvent).
91
+ function injectPendingIntoCoordinator(
92
+ coordinator: LiveCoordinator['instance'],
93
+ pending: PendingMeshCoordinatorEvent,
94
+ ): void {
95
+ if (!coordinator || !pending.coordinatorMessage) return;
96
+ const force = shouldForceInjectMeshEvent(pending.event);
97
+ coordinator.onEvent('send_message', {
98
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
99
+ ...(force ? { force: true } : {}),
100
+ });
101
+ }
102
+
103
+ // One reconcile tick across every mesh that has a live CLI coordinator here.
104
+ export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
105
+ const coordinators = findLiveCoordinators(components);
106
+ if (coordinators.length === 0) {
107
+ // No live CLI coordinator on this daemon — nothing to inject into.
108
+ // (MCP-only LLM coordinators drain the queue via their own tool calls.)
109
+ return;
110
+ }
111
+
112
+ // Group coordinators by mesh; multiple coordinator instances for one mesh is
113
+ // unusual but supported (each gets the same drained events).
114
+ const byMesh = new Map<string, LiveCoordinator[]>();
115
+ for (const c of coordinators) {
116
+ const list = byMesh.get(c.meshId);
117
+ if (list) list.push(c);
118
+ else byMesh.set(c.meshId, [c]);
119
+ }
120
+
121
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
122
+ const dispatchMeshCommand = components.dispatchMeshCommand;
123
+ const store = (() => {
124
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
125
+ })();
126
+
127
+ for (const [meshId, meshCoordinators] of byMesh) {
128
+ // (a) Cloud-only: pull remote worker node daemons' queues over P2P and
129
+ // re-inject locally. This is the same cross-daemon pull the MCP
130
+ // drainCoordinatorPendingEvents performs, lifted to the daemon timer.
131
+ // On standalone (no dispatchMeshCommand) this whole block is skipped,
132
+ // keeping cloud/standalone identical for the local case.
133
+ if (dispatchMeshCommand) {
134
+ try {
135
+ await pullRemoteNodeQueues(components, meshId, localDaemonId);
136
+ } catch (e: any) {
137
+ LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${meshId}: ${e?.message || e}`);
138
+ }
139
+ }
140
+
141
+ // (b) Drain the local queue scoped to this coordinator daemon and inject.
142
+ // - If an idle coordinator exists, FULL-drain and deliver every event to it
143
+ // (it can receive non-force progress events without deadlocking).
144
+ // - If only GENERATING coordinators exist, force-drain ONLY the force-inject
145
+ // events (completion/approval/stop/refine·bootstrap terminal) and force-inject
146
+ // them so a coordinator parked in `generating` while awaiting that very event
147
+ // is not deadlocked. Non-force progress events stay queued for the next idle
148
+ // tick — injecting them would be noise mid-generation. Both drains mark the
149
+ // consumed rows drained=1 atomically, so the pull path can't re-deliver.
150
+ const idleCoordinators = meshCoordinators.filter(c => c.idle);
151
+ const generatingCoordinators = meshCoordinators.filter(c => !c.idle);
152
+ const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
153
+ const forceOnly = idleCoordinators.length === 0;
154
+
155
+ // O(1) guard: skip the drain entirely when the queue is empty.
156
+ if (store) {
157
+ try {
158
+ if (store.pendingEventCount(meshId) === 0) continue;
159
+ } catch { /* fall through to drain */ }
160
+ }
161
+
162
+ let pendingEvents: PendingMeshCoordinatorEvent[] = [];
163
+ try {
164
+ pendingEvents = drainPendingMeshCoordinatorEvents(
165
+ meshId,
166
+ localDaemonId,
167
+ forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
168
+ );
169
+ } catch (e: any) {
170
+ LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
171
+ continue;
172
+ }
173
+ if (pendingEvents.length === 0) continue;
174
+
175
+ const mode = forceOnly ? 'force-drain → generating' : 'inject → idle';
176
+ LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
177
+ for (const pending of pendingEvents) {
178
+ for (const c of targetCoordinators) {
179
+ injectPendingIntoCoordinator(c.instance, pending);
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ // Cloud-only: poll each remote worker node daemon for pending coordinator events
186
+ // and re-inject them locally via handleMeshForwardEvent (which re-queues +
187
+ // surfaces to the live coordinator on the next tick / immediately if idle).
188
+ async function pullRemoteNodeQueues(
189
+ components: DaemonComponents,
190
+ meshId: string,
191
+ localDaemonId: string | undefined,
192
+ ): Promise<void> {
193
+ const dispatchMeshCommand = components.dispatchMeshCommand;
194
+ if (!dispatchMeshCommand) return;
195
+ const mesh = listMeshes().find(m => m.id === meshId);
196
+ if (!mesh) return;
197
+
198
+ const pendingEventArgs: Record<string, unknown> = {
199
+ meshId,
200
+ ...(localDaemonId ? { coordinatorDaemonId: localDaemonId } : {}),
201
+ };
202
+
203
+ for (const node of mesh.nodes) {
204
+ const nodeDaemonId = readNonEmptyString(node.daemonId);
205
+ // Skip nodes without a daemon, and nodes on THIS daemon (their events are
206
+ // already in the local queue drained in step (b)).
207
+ if (!nodeDaemonId) continue;
208
+ if (localDaemonId && nodeDaemonId === localDaemonId) continue;
209
+
210
+ let events: unknown;
211
+ try {
212
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
213
+ } catch {
214
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
215
+ continue;
216
+ }
217
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
218
+ for (const event of list) {
219
+ const payload = buildForwardPayloadFromPending(event);
220
+ if (!payload.event || !payload.meshId) continue;
221
+ try {
222
+ handleMeshForwardEvent(components, payload);
223
+ } catch { /* best-effort re-inject */ }
224
+ }
225
+ }
226
+ }
227
+
228
+ function extractPendingEvents(raw: unknown): any[] {
229
+ if (Array.isArray(raw)) return raw;
230
+ if (raw && typeof raw === 'object') {
231
+ const events = (raw as Record<string, unknown>).events;
232
+ if (Array.isArray(events)) return events;
233
+ }
234
+ return [];
235
+ }
236
+
237
+ // Flatten a queued PendingMeshCoordinatorEvent into the flat payload shape
238
+ // handleMeshForwardEvent expects (mirrors the MCP buildMeshForwardPayloadFromPendingEvent).
239
+ function buildForwardPayloadFromPending(event: any): Record<string, unknown> {
240
+ const metadata = event?.metadataEvent && typeof event.metadataEvent === 'object'
241
+ ? event.metadataEvent as Record<string, unknown>
242
+ : {};
243
+ return {
244
+ event: readNonEmptyString(event?.event),
245
+ meshId: readNonEmptyString(event?.meshId),
246
+ nodeId: readNonEmptyString(event?.nodeId) || readNonEmptyString(metadata.meshNodeId),
247
+ workspace: readNonEmptyString(event?.workspace) || readNonEmptyString(metadata.workspace),
248
+ ...metadata,
249
+ };
250
+ }
251
+
252
+ interface ReconcileLoopHandle {
253
+ stop(): void;
254
+ }
255
+
256
+ // Start the periodic reconcile loop. Returns a handle with stop() for shutdown.
257
+ export function setupMeshReconcileLoop(components: DaemonComponents): ReconcileLoopHandle {
258
+ const intervalMs = resolveReconcileIntervalMs();
259
+ let running = false;
260
+ const timer = setInterval(() => {
261
+ if (running) return; // never overlap ticks
262
+ running = true;
263
+ void runMeshReconcileTick(components)
264
+ .catch((e: any) => LOG.warn('MeshReconcile', `Reconcile tick error: ${e?.message || e}`))
265
+ .finally(() => { running = false; });
266
+ }, intervalMs);
267
+ // Don't keep the process alive solely for this timer.
268
+ if (typeof timer.unref === 'function') timer.unref();
269
+ LOG.info('MeshReconcile', `Mesh reconcile loop started (interval ${intervalMs}ms)`);
270
+ return {
271
+ stop() {
272
+ clearInterval(timer);
273
+ LOG.info('MeshReconcile', 'Mesh reconcile loop stopped');
274
+ },
275
+ };
276
+ }
@@ -1241,16 +1241,41 @@ export class MeshRuntimeStore {
1241
1241
  return result.changes > 0;
1242
1242
  }
1243
1243
 
1244
- drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null): Array<{ id: string; event: string; payload: unknown }> {
1244
+ /**
1245
+ * Drain undrained pending events for a mesh, atomically marking them drained.
1246
+ * When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
1247
+ * drained — the rest stay queued (drained=0) for a later drain. This is how the
1248
+ * reconcile loop force-drains terminal/force-inject events into a *generating*
1249
+ * coordinator while leaving non-force progress events for the coordinator's next
1250
+ * idle transition. Filtering happens inside the same transaction as the
1251
+ * drained=1 marking, so force-drain + a concurrent full drain can never both
1252
+ * consume the same row.
1253
+ */
1254
+ drainPendingEvents(
1255
+ meshId: string,
1256
+ coordinatorDaemonId?: string | null,
1257
+ opts?: { onlyEvents?: ReadonlySet<string> },
1258
+ ): Array<{ id: string; event: string; payload: unknown }> {
1245
1259
  return this.transaction(() => {
1246
- const whereClause = coordinatorDaemonId
1247
- ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)`
1248
- : `WHERE mesh_id = ? AND drained = 0`;
1249
- const params: unknown[] = coordinatorDaemonId
1250
- ? [meshId, coordinatorDaemonId]
1251
- : [meshId];
1260
+ const onlyEvents = opts?.onlyEvents;
1261
+ // An explicit-but-empty filter means "drain nothing" (no event name can match).
1262
+ if (onlyEvents && onlyEvents.size === 0) return [];
1263
+ const eventList = onlyEvents ? [...onlyEvents] : [];
1264
+ // Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
1265
+ // matching rows — a long run of non-force events ahead in the queue must not
1266
+ // crowd a force event out of the 100-row window.
1267
+ const clauses = ['mesh_id = ?', 'drained = 0'];
1268
+ const params: unknown[] = [meshId];
1269
+ if (coordinatorDaemonId) {
1270
+ clauses.push('(coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)');
1271
+ params.push(coordinatorDaemonId);
1272
+ }
1273
+ if (eventList.length > 0) {
1274
+ clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
1275
+ params.push(...eventList);
1276
+ }
1252
1277
  const rows = this.db.prepare(
1253
- `SELECT id, event, payload FROM mesh_pending_events ${whereClause} ORDER BY queued_at ASC LIMIT 100`
1278
+ `SELECT id, event, payload FROM mesh_pending_events WHERE ${clauses.join(' AND ')} ORDER BY queued_at ASC LIMIT 100`
1254
1279
  ).all(...params) as Array<{ id: string; event: string; payload: string }>;
1255
1280
  if (rows.length === 0) return [];
1256
1281
  const ids = rows.map(r => r.id);