@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.
- package/dist/boot/daemon-lifecycle.d.ts +3 -0
- package/dist/index.js +199 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +199 -128
- 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-missions.d.ts +10 -0
- package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
- package/dist/repo-mesh-types.d.ts +8 -0
- package/package.json +2 -2
- package/src/boot/daemon-lifecycle.ts +16 -2
- package/src/commands/router.ts +14 -21
- 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-missions.ts +21 -0
- package/src/mesh/mesh-reconcile-loop.ts +256 -0
- package/src/providers/cli-provider-instance.ts +23 -0
- package/src/repo-mesh-types.ts +8 -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
|
+
}
|
|
@@ -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
|
package/src/repo-mesh-types.ts
CHANGED
|
@@ -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
|