@adhdev/daemon-core 0.9.82-rc.284 → 0.9.82-rc.286

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.284",
3
+ "version": "0.9.82-rc.286",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.284",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.286",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -41,6 +41,7 @@
41
41
  // ---------------------------------------------------------------------------
42
42
 
43
43
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
44
+ import type { LocalMeshEntry } from '../repo-mesh-types.js';
44
45
  import { loadConfig } from '../config/config.js';
45
46
  import { listMeshes } from '../config/mesh-config.js';
46
47
  import { LOG } from '../logging/logger.js';
@@ -87,6 +88,30 @@ function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
87
88
  return [...ids];
88
89
  }
89
90
 
91
+ // Whether THIS daemon is the coordinator/host for a mesh — i.e. the daemon that
92
+ // owns coordinator ownership and must collect every worker node's completion
93
+ // events into its local queue. This is true regardless of whether a *live CLI*
94
+ // coordinator session currently exists: the coordinator is frequently a pure
95
+ // stdio MCP LLM (no live CLI session to inject into), and that LLM only sees the
96
+ // queue when it next calls a mesh tool. For it to see remote worker completions
97
+ // at all, the daemon must have already pulled them into the local queue on the
98
+ // timer — which is exactly what this predicate gates.
99
+ //
100
+ // Rule: this daemon hosts the mesh when meshHost.role is 'host' (the default for
101
+ // standalone-compat meshes with no host metadata) AND, when a hostDaemonId is
102
+ // pinned, it resolves to one of this daemon's ids. Member-only daemons return
103
+ // false — their own queue is pulled BY the host, not the other way around.
104
+ function daemonHostsMesh(mesh: LocalMeshEntry, daemonIds: string[]): boolean {
105
+ const host = mesh.meshHost;
106
+ // No metadata → default host (standalone compatibility, see createDefaultMeshHostMetadata).
107
+ if (!host) return true;
108
+ if (host.role && host.role !== 'host') return false;
109
+ const hostDaemonId = readNonEmptyString(host.hostDaemonId);
110
+ // Host role but no pinned hostDaemonId → treat as host (single-daemon / legacy).
111
+ if (!hostDaemonId) return true;
112
+ return daemonIds.includes(hostDaemonId);
113
+ }
114
+
90
115
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
91
116
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
92
117
  const out: LiveCoordinator[] = [];
@@ -118,12 +143,57 @@ function injectPendingIntoCoordinator(
118
143
  });
119
144
  }
120
145
 
121
- // One reconcile tick across every mesh that has a live CLI coordinator here.
146
+ // One reconcile tick. Two independent phases:
147
+ //
148
+ // PHASE 1 — Remote queue pull (the fix for remote worktree completions never
149
+ // reaching an MCP/LLM coordinator). For EVERY mesh this daemon hosts/
150
+ // coordinates, pull each remote worker node's pending-events queue over P2P
151
+ // into THIS daemon's local queue. This runs *regardless of whether a live CLI
152
+ // coordinator exists* — the coordinator is usually a pure stdio MCP LLM with
153
+ // no live CLI session, and it can only observe a remote worker's completion
154
+ // once that event has been pulled into the local queue (which it then drains
155
+ // on its next mesh tool call). Previously this pull was gated behind a live
156
+ // CLI coordinator and so never ran for MCP/LLM coordinators — remote
157
+ // completions sat on the remote node's queue until the LLM happened to call
158
+ // mesh_read_chat, which triggered the MCP-side pull. The daemon now does it
159
+ // autonomously on the timer. Standalone (no dispatchMeshCommand) skips this
160
+ // phase entirely — there are no remote nodes to pull from.
161
+ //
162
+ // PHASE 2 — Live CLI inject. For each mesh that has a live CLI coordinator on
163
+ // THIS daemon, drain the local queue and inject pending events into the PTY.
164
+ // Unchanged from before.
122
165
  export async function runMeshReconcileTick(components: DaemonComponents): Promise<void> {
166
+ const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
167
+ // The id-set used to scope the local queue drain (status id + machineId). See
168
+ // resolveCoordinatorDaemonIds — the status id is what the MCP layer stamps and
169
+ // is mandatory here for a generating CLI coordinator to self-receive completions.
170
+ const drainDaemonIds = resolveCoordinatorDaemonIds(components);
171
+ const dispatchMeshCommand = components.dispatchMeshCommand;
172
+ const store = (() => {
173
+ try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
174
+ })();
175
+
176
+ // ── PHASE 1: pull remote node queues for every mesh this daemon hosts ──────
177
+ // Cloud-only (dispatchMeshCommand present). Runs whether or not a live CLI
178
+ // coordinator exists — this is what lets an MCP/LLM coordinator ever see a
179
+ // remote worker's completion.
180
+ if (dispatchMeshCommand) {
181
+ for (const mesh of listMeshes()) {
182
+ if (!daemonHostsMesh(mesh, drainDaemonIds)) continue;
183
+ try {
184
+ await pullRemoteNodeQueues(components, mesh, localDaemonId, drainDaemonIds);
185
+ } catch (e: any) {
186
+ LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${mesh.id}: ${e?.message || e}`);
187
+ }
188
+ }
189
+ }
190
+
191
+ // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
123
192
  const coordinators = findLiveCoordinators(components);
124
193
  if (coordinators.length === 0) {
125
194
  // No live CLI coordinator on this daemon — nothing to inject into.
126
- // (MCP-only LLM coordinators drain the queue via their own tool calls.)
195
+ // (MCP-only LLM coordinators drain the local queue via their own tool
196
+ // calls; PHASE 1 above has already populated it from remote nodes.)
127
197
  return;
128
198
  }
129
199
 
@@ -136,31 +206,8 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
136
206
  else byMesh.set(c.meshId, [c]);
137
207
  }
138
208
 
139
- const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
140
- // The id-set used to scope the local queue drain (status id + machineId). See
141
- // resolveCoordinatorDaemonIds — the status id is what the MCP layer stamps and
142
- // is mandatory here for a generating CLI coordinator to self-receive completions.
143
- const drainDaemonIds = resolveCoordinatorDaemonIds(components);
144
- const dispatchMeshCommand = components.dispatchMeshCommand;
145
- const store = (() => {
146
- try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
147
- })();
148
-
149
209
  for (const [meshId, meshCoordinators] of byMesh) {
150
- // (a) Cloud-only: pull remote worker node daemons' queues over P2P and
151
- // re-inject locally. This is the same cross-daemon pull the MCP
152
- // drainCoordinatorPendingEvents performs, lifted to the daemon timer.
153
- // On standalone (no dispatchMeshCommand) this whole block is skipped,
154
- // keeping cloud/standalone identical for the local case.
155
- if (dispatchMeshCommand) {
156
- try {
157
- await pullRemoteNodeQueues(components, meshId, localDaemonId);
158
- } catch (e: any) {
159
- LOG.warn('MeshReconcile', `Remote node pull failed for mesh ${meshId}: ${e?.message || e}`);
160
- }
161
- }
162
-
163
- // (b) Drain the local queue scoped to this coordinator daemon and inject.
210
+ // Drain the local queue scoped to this coordinator daemon and inject.
164
211
  // - If an idle coordinator exists, FULL-drain and deliver every event to it
165
212
  // (it can receive non-force progress events without deadlocking).
166
213
  // - If only GENERATING coordinators exist, force-drain ONLY the force-inject
@@ -207,42 +254,56 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
207
254
  // Cloud-only: poll each remote worker node daemon for pending coordinator events
208
255
  // and re-inject them locally via handleMeshForwardEvent (which re-queues +
209
256
  // surfaces to the live coordinator on the next tick / immediately if idle).
257
+ //
258
+ // Scoping: the remote handler (get_pending_mesh_events) drains its queue filtered
259
+ // by coordinatorDaemonId — returning events targeted at that id OR unscoped, and
260
+ // leaving events targeted at a *different* coordinator. A remote worker stamps the
261
+ // coordinator id in one of two forms (the canonical status id `standalone_`/
262
+ // `daemon_<machineId>` stamped by the MCP layer, or the bare machineId stamped by
263
+ // the local queue path). We therefore pull ONCE PER candidate coordinator id
264
+ // (drainDaemonIds = both forms) so a completion stamped with either form is
265
+ // recovered. The remote drain is atomic (drained=1), so issuing both pulls cannot
266
+ // double-deliver — the first pull that matches consumes the event; the second sees
267
+ // nothing. When no ids resolve we fall back to a single unscoped pull.
210
268
  async function pullRemoteNodeQueues(
211
269
  components: DaemonComponents,
212
- meshId: string,
270
+ mesh: LocalMeshEntry,
213
271
  localDaemonId: string | undefined,
272
+ drainDaemonIds: string[],
214
273
  ): Promise<void> {
215
274
  const dispatchMeshCommand = components.dispatchMeshCommand;
216
275
  if (!dispatchMeshCommand) return;
217
- const mesh = listMeshes().find(m => m.id === meshId);
218
- if (!mesh) return;
276
+ const meshId = mesh.id;
219
277
 
220
- const pendingEventArgs: Record<string, unknown> = {
221
- meshId,
222
- ...(localDaemonId ? { coordinatorDaemonId: localDaemonId } : {}),
223
- };
278
+ // One args object per coordinator-id form (status id + bare machineId), or a
279
+ // single unscoped pull when neither resolves.
280
+ const pulls: Array<Record<string, unknown>> = drainDaemonIds.length > 0
281
+ ? drainDaemonIds.map(id => ({ meshId, coordinatorDaemonId: id }))
282
+ : [{ meshId }];
224
283
 
225
284
  for (const node of mesh.nodes) {
226
285
  const nodeDaemonId = readNonEmptyString(node.daemonId);
227
286
  // Skip nodes without a daemon, and nodes on THIS daemon (their events are
228
- // already in the local queue drained in step (b)).
287
+ // already in the local queue drained in PHASE 2).
229
288
  if (!nodeDaemonId) continue;
230
289
  if (localDaemonId && nodeDaemonId === localDaemonId) continue;
231
290
 
232
- let events: unknown;
233
- try {
234
- events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
235
- } catch {
236
- // Remote pull is best-effort; the node may be offline. Retry next tick.
237
- continue;
238
- }
239
- const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
240
- for (const event of list) {
241
- const payload = buildForwardPayloadFromPending(event);
242
- if (!payload.event || !payload.meshId) continue;
291
+ for (const pendingEventArgs of pulls) {
292
+ let events: unknown;
243
293
  try {
244
- handleMeshForwardEvent(components, payload);
245
- } catch { /* best-effort re-inject */ }
294
+ events = await dispatchMeshCommand(nodeDaemonId, 'get_pending_mesh_events', pendingEventArgs);
295
+ } catch {
296
+ // Remote pull is best-effort; the node may be offline. Retry next tick.
297
+ break; // node unreachable — don't bother with the other id form this tick.
298
+ }
299
+ const list = extractPendingEvents(events).filter(e => readNonEmptyString(e?.meshId) === meshId);
300
+ for (const event of list) {
301
+ const payload = buildForwardPayloadFromPending(event);
302
+ if (!payload.event || !payload.meshId) continue;
303
+ try {
304
+ handleMeshForwardEvent(components, payload);
305
+ } catch { /* best-effort re-inject */ }
306
+ }
246
307
  }
247
308
  }
248
309
  }