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

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.
@@ -31,11 +31,11 @@ export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoord
31
31
  * the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
32
32
  * atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
33
33
  */
34
- export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string, opts?: {
34
+ export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>, opts?: {
35
35
  onlyEvents?: ReadonlySet<string>;
36
36
  }): PendingMeshCoordinatorEvent[];
37
37
  /** Peek at pending coordinator events without draining (non-destructive). */
38
- export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): readonly PendingMeshCoordinatorEvent[];
38
+ export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[];
39
39
  /**
40
40
  * Test helper: purge all pending-event state for a mesh — SQLite rows
41
41
  * (including drained fingerprint history) and JSONL files.
@@ -298,7 +298,7 @@ export declare class MeshRuntimeStore {
298
298
  * drained=1 marking, so force-drain + a concurrent full drain can never both
299
299
  * consume the same row.
300
300
  */
301
- drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null, opts?: {
301
+ drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null | ReadonlyArray<string>, opts?: {
302
302
  onlyEvents?: ReadonlySet<string>;
303
303
  }): Array<{
304
304
  id: string;
@@ -306,7 +306,7 @@ export declare class MeshRuntimeStore {
306
306
  payload: unknown;
307
307
  }>;
308
308
  /** Non-destructive peek — returns undrained events without marking them drained. */
309
- peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null): Array<{
309
+ peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null | ReadonlyArray<string>): Array<{
310
310
  id: string;
311
311
  event: string;
312
312
  payload: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.275",
3
+ "version": "0.9.82-rc.277",
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.275",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.277",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -123,6 +123,14 @@ export interface DaemonComponents {
123
123
  // single-model (queue + polling) delivery: drains the pending-events queue
124
124
  // on a fixed interval and injects into live CLI coordinators when idle.
125
125
  meshReconcileLoop?: { stop(): void };
126
+ // Canonical status/daemon identity (e.g. `standalone_<machineId>` /
127
+ // `daemon_<machineId>`). This is the SAME id the MCP layer stamps as a
128
+ // worker's meshCoordinatorDaemonId (ctx.localDaemonId, sourced from
129
+ // getStatus().status.instanceId), so the reconcile loop MUST drain with it —
130
+ // draining with bare loadConfig().machineId never matches a unicast event
131
+ // stamped with the prefixed status id. Absent → reconcile falls back to
132
+ // machineId only.
133
+ statusInstanceId?: string;
126
134
  }
127
135
 
128
136
  export interface DaemonDevSupportOptions {
@@ -362,6 +370,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
362
370
  refreshProviderAvailability,
363
371
  dispatchMeshCommand: config.dispatchMeshCommand,
364
372
  onMeshCoordinatorEventForwarded: config.onMeshCoordinatorEventForwarded,
373
+ statusInstanceId: config.statusInstanceId,
365
374
  };
366
375
 
367
376
  // 11. Setup Mesh Event Forwarding (queue persistence) + periodic reconcile loop.
@@ -29,6 +29,19 @@ import {
29
29
  readWorkerResultMetadata,
30
30
  } from './mesh-events-utils.js';
31
31
 
32
+ // The set of coordinator-daemon ids this daemon answers to when draining the
33
+ // pending-events queue (canonical status id + bare machineId). Mirrors
34
+ // resolveCoordinatorDaemonIds in mesh-reconcile-loop — a unicast event may be
35
+ // stamped with either id depending on which dispatch path created the worker.
36
+ function resolveCoordinatorDrainDaemonIds(components: DaemonComponents): string[] {
37
+ const ids = new Set<string>();
38
+ const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
39
+ if (statusInstanceId) ids.add(statusInstanceId);
40
+ const machineId = readNonEmptyString(loadConfig().machineId);
41
+ if (machineId) ids.add(machineId);
42
+ return [...ids];
43
+ }
44
+
32
45
  // ---------------------------------------------------------------------------
33
46
  // Remote Node Idle Session Tracking
34
47
  // ---------------------------------------------------------------------------
@@ -1506,8 +1519,13 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1506
1519
  const status = readNonEmptyString(flushState.status).toLowerCase();
1507
1520
  if (status === 'idle') {
1508
1521
  try {
1509
- const localDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
1510
- const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, localDaemonId);
1522
+ // Drain with the daemon's full coordinator-id set (status id + machineId).
1523
+ // The MCP layer stamps the prefixed status id (`standalone_<machineId>` /
1524
+ // `daemon_<machineId>`) as the worker's meshCoordinatorDaemonId; draining
1525
+ // with bare machineId alone would miss those unicast events. Mirrors
1526
+ // resolveCoordinatorDaemonIds in mesh-reconcile-loop.
1527
+ const drainDaemonIds = resolveCoordinatorDrainDaemonIds(components);
1528
+ const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, drainDaemonIds.length > 0 ? drainDaemonIds : undefined);
1511
1529
  if (pendingEvents.length > 0) {
1512
1530
  LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
1513
1531
  for (const pending of pendingEvents) {
@@ -37,6 +37,27 @@ export interface PendingMeshCoordinatorEvent {
37
37
 
38
38
  const REFINE_TERMINAL_EVENTS = new Set(['refine:completed', 'refine:failed']);
39
39
 
40
+ /** Normalise a coordinator-daemon-id argument (single id, list, or undefined) into a
41
+ * de-duplicated list of non-empty strings. The first entry is treated as primary for
42
+ * per-daemon JSONL file naming; all entries are accepted by drain/peek targeting. */
43
+ function normalizeCoordinatorDaemonIds(
44
+ coordinatorDaemonId?: string | null | ReadonlyArray<string>,
45
+ ): string[] {
46
+ const raw = Array.isArray(coordinatorDaemonId)
47
+ ? coordinatorDaemonId
48
+ : coordinatorDaemonId != null ? [coordinatorDaemonId] : [];
49
+ const seen = new Set<string>();
50
+ const out: string[] = [];
51
+ for (const id of raw) {
52
+ if (typeof id !== 'string') continue;
53
+ const trimmed = id.trim();
54
+ if (!trimmed || seen.has(trimmed)) continue;
55
+ seen.add(trimmed);
56
+ out.push(trimmed);
57
+ }
58
+ return out;
59
+ }
60
+
40
61
  export function readRefineJobId(event: { metadataEvent?: Record<string, unknown> } | Record<string, unknown>): string {
41
62
  const metadata = readRecord((event as any).metadataEvent) || event as Record<string, unknown>;
42
63
  const result = readRecord(metadata.result);
@@ -108,11 +129,13 @@ function getPendingEventsPath(meshId: string, coordinatorDaemonId?: string): str
108
129
  return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
109
130
  }
110
131
 
111
- function readPendingMeshCoordinatorEventsFromDisk(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[] {
132
+ function readPendingMeshCoordinatorEventsFromDisk(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): PendingMeshCoordinatorEvent[] {
112
133
  if (!meshId) return [];
134
+ const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
135
+ const primaryDaemonId = daemonIds[0];
113
136
  // Read coordinator-scoped file first; fall back to legacy shared file.
114
- const paths = coordinatorDaemonId
115
- ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
137
+ const paths = primaryDaemonId
138
+ ? [getPendingEventsPath(meshId, primaryDaemonId), getPendingEventsPath(meshId)]
116
139
  : [getPendingEventsPath(meshId)];
117
140
  const events: PendingMeshCoordinatorEvent[] = [];
118
141
  for (const path of paths) {
@@ -123,8 +146,8 @@ function readPendingMeshCoordinatorEventsFromDisk(meshId?: string, coordinatorDa
123
146
  try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
124
147
  });
125
148
  // If reading the shared file, filter to events that target this coordinator or are unscoped.
126
- const filtered = (coordinatorDaemonId && path === getPendingEventsPath(meshId))
127
- ? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
149
+ const filtered = (primaryDaemonId && path === getPendingEventsPath(meshId))
150
+ ? parsed.filter(e => !e.targetCoordinatorDaemonId || daemonIds.includes(e.targetCoordinatorDaemonId))
128
151
  : parsed;
129
152
  events.push(...filtered);
130
153
  } catch { /* skip unreadable files */ }
@@ -342,11 +365,18 @@ function selectiveDrainFile(
342
365
  */
343
366
  export function drainPendingMeshCoordinatorEvents(
344
367
  meshId?: string,
345
- coordinatorDaemonId?: string,
368
+ coordinatorDaemonId?: string | ReadonlyArray<string>,
346
369
  opts?: { onlyEvents?: ReadonlySet<string> },
347
370
  ): PendingMeshCoordinatorEvent[] {
348
371
  if (!meshId) return [];
349
372
 
373
+ // A daemon may answer to more than one coordinator-id form (its canonical
374
+ // status id like `standalone_<machineId>` AND the bare machineId). Normalise
375
+ // to a list so both the SQLite IN-filter and the JSONL targeting predicate
376
+ // accept any of them.
377
+ const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
378
+ const primaryDaemonId = daemonIds[0];
379
+
350
380
  const onlyEvents = opts?.onlyEvents;
351
381
  const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
352
382
 
@@ -368,7 +398,7 @@ export function drainPendingMeshCoordinatorEvents(
368
398
  try {
369
399
  const store = MeshRuntimeStore.getInstance();
370
400
  if (store.pendingEventCount(meshId) > 0) {
371
- for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId, onlyEvents ? { onlyEvents } : undefined)) {
401
+ for (const row of store.drainPendingEvents(meshId, daemonIds.length > 0 ? daemonIds : undefined, onlyEvents ? { onlyEvents } : undefined)) {
372
402
  const event = row.payload as PendingMeshCoordinatorEvent;
373
403
  if (event) pushUnique(event);
374
404
  }
@@ -377,16 +407,18 @@ export function drainPendingMeshCoordinatorEvents(
377
407
  // SQLite drain failed — JSONL below still drains
378
408
  }
379
409
 
380
- // JSONL (legacy / migration path) — always drained alongside SQLite
381
- const paths = coordinatorDaemonId
382
- ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
410
+ // JSONL (legacy / migration path) — always drained alongside SQLite.
411
+ // The scoped per-daemon file is keyed by a single id; use the primary. The
412
+ // shared (unscoped) file's targeting predicate accepts ANY of this daemon's ids.
413
+ const paths = primaryDaemonId
414
+ ? [getPendingEventsPath(meshId, primaryDaemonId), getPendingEventsPath(meshId)]
383
415
  : [getPendingEventsPath(meshId)];
384
416
  for (const path of paths) {
385
- const isSharedFile = coordinatorDaemonId && path === getPendingEventsPath(meshId);
417
+ const isSharedFile = !!primaryDaemonId && path === getPendingEventsPath(meshId);
386
418
  // Targeting predicate for the shared (unscoped) file: only this coordinator's
387
419
  // events (or legacy untargeted ones) are eligible.
388
420
  const targets = (e: PendingMeshCoordinatorEvent): boolean =>
389
- !isSharedFile || !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId;
421
+ !isSharedFile || !e.targetCoordinatorDaemonId || daemonIds.includes(e.targetCoordinatorDaemonId);
390
422
 
391
423
  if (onlyEvents) {
392
424
  // Selective JSONL drain: consume only matching events, rewrite the rest back.
@@ -413,8 +445,9 @@ export function drainPendingMeshCoordinatorEvents(
413
445
  }
414
446
 
415
447
  /** Peek at pending coordinator events without draining (non-destructive). */
416
- export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): readonly PendingMeshCoordinatorEvent[] {
448
+ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string | ReadonlyArray<string>): readonly PendingMeshCoordinatorEvent[] {
417
449
  if (!meshId) return [];
450
+ const daemonIds = normalizeCoordinatorDaemonIds(coordinatorDaemonId);
418
451
 
419
452
  // Merge SQLite (primary) + JSONL (legacy) with fingerprint dedup.
420
453
  const merged: PendingMeshCoordinatorEvent[] = [];
@@ -432,7 +465,7 @@ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaem
432
465
  try {
433
466
  const store = MeshRuntimeStore.getInstance();
434
467
  if (store.pendingEventCount(meshId) > 0) {
435
- for (const row of store.peekPendingEvents(meshId, coordinatorDaemonId)) {
468
+ for (const row of store.peekPendingEvents(meshId, daemonIds.length > 0 ? daemonIds : undefined)) {
436
469
  const event = row.payload as PendingMeshCoordinatorEvent;
437
470
  if (event) pushUnique(event);
438
471
  }
@@ -440,7 +473,7 @@ export function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaem
440
473
  } catch { /* SQLite unavailable — JSONL fallback below */ }
441
474
 
442
475
  // JSONL (legacy)
443
- for (const event of readPendingMeshCoordinatorEventsFromDisk(meshId, coordinatorDaemonId)) {
476
+ for (const event of readPendingMeshCoordinatorEventsFromDisk(meshId, daemonIds)) {
444
477
  pushUnique(event);
445
478
  }
446
479
 
@@ -69,6 +69,24 @@ interface LiveCoordinator {
69
69
  idle: boolean;
70
70
  }
71
71
 
72
+ // The set of coordinator-daemon ids THIS daemon answers to when draining the
73
+ // pending-events queue. A unicast completion event is stamped with the worker's
74
+ // meshCoordinatorDaemonId, which can be either:
75
+ // - the daemon's canonical status id (`standalone_<machineId>` / `daemon_<machineId>`),
76
+ // stamped by the MCP layer via ctx.localDaemonId (= getStatus().status.instanceId), or
77
+ // - the bare machineId, stamped by the local queue-assignment path (loadConfig().machineId).
78
+ // Draining with only one of these silently misses events stamped with the other —
79
+ // the exact reason a generating coordinator never self-received local completions.
80
+ // We accept BOTH so the drain matches regardless of which path stamped the event.
81
+ function resolveCoordinatorDaemonIds(components: DaemonComponents): string[] {
82
+ const ids = new Set<string>();
83
+ const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
84
+ if (statusInstanceId) ids.add(statusInstanceId);
85
+ const machineId = readNonEmptyString(loadConfig().machineId);
86
+ if (machineId) ids.add(machineId);
87
+ return [...ids];
88
+ }
89
+
72
90
  // Find live CLI coordinator instances on THIS daemon, keyed by mesh.
73
91
  function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
74
92
  const out: LiveCoordinator[] = [];
@@ -119,6 +137,10 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
119
137
  }
120
138
 
121
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);
122
144
  const dispatchMeshCommand = components.dispatchMeshCommand;
123
145
  const store = (() => {
124
146
  try { return MeshRuntimeStore.getInstance(); } catch { return undefined; }
@@ -163,7 +185,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
163
185
  try {
164
186
  pendingEvents = drainPendingMeshCoordinatorEvents(
165
187
  meshId,
166
- localDaemonId,
188
+ drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
167
189
  forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
168
190
  );
169
191
  } catch (e: any) {
@@ -1253,7 +1253,7 @@ export class MeshRuntimeStore {
1253
1253
  */
1254
1254
  drainPendingEvents(
1255
1255
  meshId: string,
1256
- coordinatorDaemonId?: string | null,
1256
+ coordinatorDaemonId?: string | null | ReadonlyArray<string>,
1257
1257
  opts?: { onlyEvents?: ReadonlySet<string> },
1258
1258
  ): Array<{ id: string; event: string; payload: unknown }> {
1259
1259
  return this.transaction(() => {
@@ -1261,14 +1261,23 @@ export class MeshRuntimeStore {
1261
1261
  // An explicit-but-empty filter means "drain nothing" (no event name can match).
1262
1262
  if (onlyEvents && onlyEvents.size === 0) return [];
1263
1263
  const eventList = onlyEvents ? [...onlyEvents] : [];
1264
+ // A coordinator daemon can answer to more than one id form: its canonical
1265
+ // status id (e.g. `standalone_<machineId>` / `daemon_<machineId>`, which the
1266
+ // MCP layer stamps via ctx.localDaemonId) AND the bare machineId (stamped by
1267
+ // the local queue-assignment path). Accept ANY of them so a unicast event
1268
+ // stamped with either id is drained here. Unscoped (NULL) rows always match.
1269
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
1270
+ ? coordinatorDaemonId
1271
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
1272
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
1264
1273
  // Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
1265
1274
  // matching rows — a long run of non-force events ahead in the queue must not
1266
1275
  // crowd a force event out of the 100-row window.
1267
1276
  const clauses = ['mesh_id = ?', 'drained = 0'];
1268
1277
  const params: unknown[] = [meshId];
1269
- if (coordinatorDaemonId) {
1270
- clauses.push('(coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)');
1271
- params.push(coordinatorDaemonId);
1278
+ if (daemonIds.length > 0) {
1279
+ clauses.push(`(coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`);
1280
+ params.push(...daemonIds);
1272
1281
  }
1273
1282
  if (eventList.length > 0) {
1274
1283
  clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
@@ -1292,11 +1301,15 @@ export class MeshRuntimeStore {
1292
1301
  }
1293
1302
 
1294
1303
  /** Non-destructive peek — returns undrained events without marking them drained. */
1295
- peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null): Array<{ id: string; event: string; payload: unknown }> {
1296
- const whereClause = coordinatorDaemonId
1297
- ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)`
1304
+ peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null | ReadonlyArray<string>): Array<{ id: string; event: string; payload: unknown }> {
1305
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
1306
+ ? coordinatorDaemonId
1307
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
1308
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
1309
+ const whereClause = daemonIds.length > 0
1310
+ ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`
1298
1311
  : `WHERE mesh_id = ? AND drained = 0`;
1299
- const params: unknown[] = coordinatorDaemonId ? [meshId, coordinatorDaemonId] : [meshId];
1312
+ const params: unknown[] = daemonIds.length > 0 ? [meshId, ...daemonIds] : [meshId];
1300
1313
  const rows = this.db.prepare(
1301
1314
  `SELECT id, event, payload FROM mesh_pending_events ${whereClause} ORDER BY queued_at ASC LIMIT 100`
1302
1315
  ).all(...params) as Array<{ id: string; event: string; payload: string }>;