@adhdev/daemon-core 0.9.82-rc.272 → 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
+ }
@@ -1578,9 +1578,23 @@ export class CliProviderInstance implements ProviderInstance {
1578
1578
  if (!this.generatingStartedAt) this.generatingStartedAt = now;
1579
1579
  const modal = adapterStatus.activeModal;
1580
1580
  LOG.info('CLI', `[${this.type}] approval modal: "${modal?.message?.slice(0, 80) ?? 'none'}"`);
1581
+ // Include the FSM's approval entry seq, mirroring the auto-approve
1582
+ // path (maybeAutoApproveStatus) and resolveModal's sameEntryReResolve
1583
+ // guard. Two distinct back-to-back approvals can carry identical
1584
+ // message/buttons (very common with claude-cli's "Allow Bash
1585
+ // command?"). Without the seq their fingerprints collide and the dedup
1586
+ // below silently drops the second waiting_approval event — it is never
1587
+ // emitted, so it cannot even land in the pending inbox for a later
1588
+ // read_chat reconcile to recover. The seq is bumped by the FSM on every
1589
+ // fresh waiting_approval entry, so a new approval always yields a new
1590
+ // fingerprint and emits.
1591
+ const approvalEntrySeq = typeof adapterStatus?.approvalEntrySeq === 'number'
1592
+ ? adapterStatus.approvalEntrySeq
1593
+ : 0;
1581
1594
  const approvalFingerprint = JSON.stringify({
1582
1595
  message: typeof modal?.message === 'string' ? modal.message.trim() : '',
1583
1596
  buttons: Array.isArray(modal?.buttons) ? modal.buttons.map((button: unknown) => String(button).trim()) : [],
1597
+ seq: approvalEntrySeq,
1584
1598
  });
1585
1599
  // PTY redraws repeat the same modal content; fingerprint dedup prevents duplicate events.
1586
1600
  // Do NOT also gate on lastStatus: consecutive approvals can arrive waiting_approval→waiting_approval
@@ -1598,6 +1612,15 @@ export class CliProviderInstance implements ProviderInstance {
1598
1612
  modalButtons: modal?.buttons,
1599
1613
  });
1600
1614
  }
1615
+ } else if (newStatus === 'generating' && this.lastStatus === 'waiting_approval') {
1616
+ // Approval resolved and the agent resumed work. Defense-in-depth:
1617
+ // clear the approval emit fingerprint here too (not only on
1618
+ // completion at scheduleCompletedDebounceFlush). A subsequent
1619
+ // waiting_approval with the same modal content as the one just
1620
+ // resolved would otherwise collide with the stale fingerprint and be
1621
+ // dropped. The seq in the fingerprint already separates entries; this
1622
+ // reset is a belt-and-suspenders guard for the re-entry case.
1623
+ this.lastApprovalEventFingerprint = '';
1601
1624
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
1602
1625
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
1603
1626
  // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { GitRepoStatus, GitCompactSummary } from './git/git-types.js';
15
+ import type { MeshMissionSummary } from './mesh/mesh-missions.js';
15
16
 
16
17
  // ─── Core Mesh Types ────────────────────────────
17
18
 
@@ -405,6 +406,13 @@ export interface RepoMeshStatus {
405
406
  nodes: RepoMeshNodeStatus[];
406
407
  queue?: RepoMeshQueueStatus;
407
408
  ledger?: RepoMeshLedgerStatus;
409
+ /**
410
+ * Mission summaries for the dashboard overview. Active/paused missions plus a
411
+ * capped, newest-first slice of completed/abandoned history. Omitted by older
412
+ * daemons — the dashboard must treat this as optional and render an empty
413
+ * state when absent. Split on each entry's `status` for live vs. history.
414
+ */
415
+ missions?: MeshMissionSummary[];
408
416
  }
409
417
 
410
418
  // RepoMeshSessionStatus shape now lives in @adhdev/mesh-shared (shared with