@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.
- package/dist/boot/daemon-lifecycle.d.ts +3 -0
- package/dist/index.js +183 -115
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +183 -115
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events-coordinator.d.ts +1 -21
- package/dist/mesh/mesh-events-pending.d.ts +0 -7
- package/dist/mesh/mesh-events.d.ts +2 -1
- package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
- package/package.json +2 -2
- package/src/boot/daemon-lifecycle.ts +16 -2
- package/src/mesh/mesh-events-coordinator.ts +31 -107
- package/src/mesh/mesh-events-pending.ts +14 -56
- package/src/mesh/mesh-events.ts +5 -1
- package/src/mesh/mesh-reconcile-loop.ts +256 -0
|
@@ -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
|
+
}
|