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

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.
@@ -28,6 +28,7 @@ export interface MeshQueueTriggerResult {
28
28
  }
29
29
  export declare function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult>;
30
30
  export declare function isMeshCoordinatorEvent(eventName: unknown): eventName is string;
31
+ export declare const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string>;
31
32
  export declare function shouldForceInjectMeshEvent(eventName: unknown): boolean;
32
33
  export declare function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>): {
33
34
  success: boolean;
@@ -20,8 +20,20 @@ export declare function readRefineJobId(event: {
20
20
  export declare function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string;
21
21
  export declare function hasPendingCoordinatorEventDuplicate(event: PendingMeshCoordinatorEvent): boolean;
22
22
  export declare function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean;
23
- /** Drain and return all pending coordinator events for meshId, removing them from disk. */
24
- export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[];
23
+ /**
24
+ * Drain and return pending coordinator events for meshId, removing the drained
25
+ * ones from both the SQLite inbox and the JSONL legacy file.
26
+ *
27
+ * When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
28
+ * drained; every other event stays queued (undrained in SQLite, rewritten back to
29
+ * the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
30
+ * events into a *generating* coordinator while leaving non-force progress events for
31
+ * the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
32
+ * atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
33
+ */
34
+ export declare function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string, opts?: {
35
+ onlyEvents?: ReadonlySet<string>;
36
+ }): PendingMeshCoordinatorEvent[];
25
37
  /** Peek at pending coordinator events without draining (non-destructive). */
26
38
  export declare function getPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): readonly PendingMeshCoordinatorEvent[];
27
39
  /**
@@ -288,7 +288,19 @@ export declare class MeshRuntimeStore {
288
288
  fingerprint?: string | null;
289
289
  queuedAt: number;
290
290
  }): boolean;
291
- drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null): Array<{
291
+ /**
292
+ * Drain undrained pending events for a mesh, atomically marking them drained.
293
+ * When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
294
+ * drained — the rest stay queued (drained=0) for a later drain. This is how the
295
+ * reconcile loop force-drains terminal/force-inject events into a *generating*
296
+ * coordinator while leaving non-force progress events for the coordinator's next
297
+ * idle transition. Filtering happens inside the same transaction as the
298
+ * drained=1 marking, so force-drain + a concurrent full drain can never both
299
+ * consume the same row.
300
+ */
301
+ drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null, opts?: {
302
+ onlyEvents?: ReadonlySet<string>;
303
+ }): Array<{
292
304
  id: string;
293
305
  event: string;
294
306
  payload: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.274",
3
+ "version": "0.9.82-rc.275",
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.274",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.275",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -975,7 +975,7 @@ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string
975
975
  // only flushed on the coordinator's OWN idle transition. That transition can't
976
976
  // happen until it receives this very event → deadlock. We force-inject these so
977
977
  // they bypass the busy send-guard and land in the PTY while generating.
978
- const MESH_FORCE_INJECT_EVENTS = new Set([
978
+ export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
979
979
  'agent:generating_completed',
980
980
  'agent:stopped',
981
981
  'agent:waiting_approval',
@@ -279,10 +279,77 @@ function atomicDrainFile(path: string): string | null {
279
279
  }
280
280
  }
281
281
 
282
- /** Drain and return all pending coordinator events for meshId, removing them from disk. */
283
- export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDaemonId?: string): PendingMeshCoordinatorEvent[] {
282
+ // Selectively drain a JSONL pending-events file: atomically claim it (rename), then
283
+ // consume only the lines whose parsed event matches `predicate` and rewrite the
284
+ // remaining (kept) lines back to the original path. Unparseable lines are kept
285
+ // untouched. Returns the consumed events. The rename makes claiming exclusive —
286
+ // only one concurrent caller wins, so there is no double-consume of the same lines.
287
+ function selectiveDrainFile(
288
+ path: string,
289
+ predicate: (event: PendingMeshCoordinatorEvent) => boolean,
290
+ ): PendingMeshCoordinatorEvent[] {
291
+ const tmpPath = `${path}.draining`;
292
+ try {
293
+ renameSync(path, tmpPath);
294
+ } catch {
295
+ return []; // another drain claimed it, or the file doesn't exist
296
+ }
297
+ let content: string;
298
+ try {
299
+ content = readFileSync(tmpPath, 'utf-8');
300
+ } catch {
301
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
302
+ return [];
303
+ }
304
+
305
+ const consumed: PendingMeshCoordinatorEvent[] = [];
306
+ const keptLines: string[] = [];
307
+ for (const line of content.split('\n')) {
308
+ if (!line) continue;
309
+ let parsed: PendingMeshCoordinatorEvent | undefined;
310
+ try { parsed = JSON.parse(line) as PendingMeshCoordinatorEvent; } catch { parsed = undefined; }
311
+ if (parsed && predicate(parsed)) {
312
+ consumed.push(parsed);
313
+ } else {
314
+ keptLines.push(line); // non-matching or unparseable → leave queued
315
+ }
316
+ }
317
+
318
+ try {
319
+ if (keptLines.length > 0) {
320
+ writeFileSync(path, keptLines.join('\n') + '\n', 'utf-8');
321
+ }
322
+ unlinkSync(tmpPath);
323
+ } catch {
324
+ // If the rewrite/cleanup fails, restore the claimed file so no events are
325
+ // lost — the next drain retries the whole file.
326
+ try { if (existsSync(tmpPath) && !existsSync(path)) renameSync(tmpPath, path); } catch { /* best-effort */ }
327
+ return [];
328
+ }
329
+ return consumed;
330
+ }
331
+
332
+ /**
333
+ * Drain and return pending coordinator events for meshId, removing the drained
334
+ * ones from both the SQLite inbox and the JSONL legacy file.
335
+ *
336
+ * When `opts.onlyEvents` is supplied, ONLY events whose name is in that set are
337
+ * drained; every other event stays queued (undrained in SQLite, rewritten back to
338
+ * the JSONL file). The reconcile loop uses this to force-drain terminal/force-inject
339
+ * events into a *generating* coordinator while leaving non-force progress events for
340
+ * the coordinator's next idle transition. The atomic SQLite drained=1 marking and the
341
+ * atomic JSONL rename keep force-drain and a concurrent full drain from double-consuming.
342
+ */
343
+ export function drainPendingMeshCoordinatorEvents(
344
+ meshId?: string,
345
+ coordinatorDaemonId?: string,
346
+ opts?: { onlyEvents?: ReadonlySet<string> },
347
+ ): PendingMeshCoordinatorEvent[] {
284
348
  if (!meshId) return [];
285
349
 
350
+ const onlyEvents = opts?.onlyEvents;
351
+ const matchesFilter = (eventName: string): boolean => !onlyEvents || onlyEvents.has(eventName);
352
+
286
353
  // Dual-write means SQLite and JSONL hold the same events. Both stores must be
287
354
  // emptied in one drain call — draining only one leaves the other to re-deliver
288
355
  // the same events on the next call. Merge with fingerprint dedup.
@@ -301,7 +368,7 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
301
368
  try {
302
369
  const store = MeshRuntimeStore.getInstance();
303
370
  if (store.pendingEventCount(meshId) > 0) {
304
- for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId)) {
371
+ for (const row of store.drainPendingEvents(meshId, coordinatorDaemonId, onlyEvents ? { onlyEvents } : undefined)) {
305
372
  const event = row.payload as PendingMeshCoordinatorEvent;
306
373
  if (event) pushUnique(event);
307
374
  }
@@ -315,15 +382,26 @@ export function drainPendingMeshCoordinatorEvents(meshId?: string, coordinatorDa
315
382
  ? [getPendingEventsPath(meshId, coordinatorDaemonId), getPendingEventsPath(meshId)]
316
383
  : [getPendingEventsPath(meshId)];
317
384
  for (const path of paths) {
385
+ const isSharedFile = coordinatorDaemonId && path === getPendingEventsPath(meshId);
386
+ // Targeting predicate for the shared (unscoped) file: only this coordinator's
387
+ // events (or legacy untargeted ones) are eligible.
388
+ const targets = (e: PendingMeshCoordinatorEvent): boolean =>
389
+ !isSharedFile || !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId;
390
+
391
+ if (onlyEvents) {
392
+ // Selective JSONL drain: consume only matching events, rewrite the rest back.
393
+ for (const event of selectiveDrainFile(path, e => targets(e) && matchesFilter(e.event))) {
394
+ pushUnique(event);
395
+ }
396
+ continue;
397
+ }
318
398
  const content = atomicDrainFile(path);
319
399
  if (!content) continue;
320
400
  const parsed = content.split('\n').filter(Boolean).flatMap(line => {
321
401
  try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
322
402
  });
323
403
  // If reading the shared file, filter to events that target this coordinator or are unscoped.
324
- const filtered = (coordinatorDaemonId && path === getPendingEventsPath(meshId))
325
- ? parsed.filter(e => !e.targetCoordinatorDaemonId || e.targetCoordinatorDaemonId === coordinatorDaemonId)
326
- : parsed;
404
+ const filtered = isSharedFile ? parsed.filter(targets) : parsed;
327
405
  for (const event of filtered) pushUnique(event);
328
406
  }
329
407
  if (merged.length === 0) return [];
@@ -14,10 +14,16 @@
14
14
  //
15
15
  // This loop is that drainer. On a fixed interval it:
16
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.)
17
+ // stamp). For each mesh, drains the local queue scoped to this daemon and
18
+ // injects pending events into the coordinator. When a coordinator is idle it
19
+ // receives every queued event. When ONLY generating coordinators exist (the
20
+ // common case while the coordinator is blocked awaiting a worker result), the
21
+ // loop force-drains ONLY the force-inject events (completion / approval / stop /
22
+ // refine·bootstrap terminal) and force-writes them into the generating PTY —
23
+ // the same busy-bypass send-guard escape the live-CLI inject used to use.
24
+ // Non-force progress events stay queued for the next idle tick (injecting them
25
+ // mid-generation would be noise). This is what makes a coordinator parked in
26
+ // `generating` while awaiting a worker's completion actually receive it.
21
27
  // 2. In cloud mode (dispatchMeshCommand present), pulls each remote worker
22
28
  // node daemon's queue over P2P (get_pending_mesh_events) and re-injects via
23
29
  // handleMeshForwardEvent — the same pull the MCP drainCoordinatorPendingEvents
@@ -41,7 +47,7 @@ import { LOG } from '../logging/logger.js';
41
47
  import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
42
48
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
43
49
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
44
- import { handleMeshForwardEvent, shouldForceInjectMeshEvent } from './mesh-events-coordinator.js';
50
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
45
51
  import { readNonEmptyString } from './mesh-events-utils.js';
46
52
 
47
53
  // Default reconcile cadence. approval/completion notifications to a live CLI
@@ -79,7 +85,9 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
79
85
  return out;
80
86
  }
81
87
 
82
- // Inject a drained pending event into a live, idle coordinator session.
88
+ // Inject a drained pending event into a live coordinator session. Force-inject
89
+ // events carry force:true so they bypass the busy send-guard and land in the PTY
90
+ // even while the coordinator is generating (see shouldForceInjectMeshEvent).
83
91
  function injectPendingIntoCoordinator(
84
92
  coordinator: LiveCoordinator['instance'],
85
93
  pending: PendingMeshCoordinatorEvent,
@@ -130,12 +138,19 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
130
138
  }
131
139
  }
132
140
 
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).
141
+ // (b) Drain the local queue scoped to this coordinator daemon and inject.
142
+ // - If an idle coordinator exists, FULL-drain and deliver every event to it
143
+ // (it can receive non-force progress events without deadlocking).
144
+ // - If only GENERATING coordinators exist, force-drain ONLY the force-inject
145
+ // events (completion/approval/stop/refine·bootstrap terminal) and force-inject
146
+ // them so a coordinator parked in `generating` while awaiting that very event
147
+ // is not deadlocked. Non-force progress events stay queued for the next idle
148
+ // tick — injecting them would be noise mid-generation. Both drains mark the
149
+ // consumed rows drained=1 atomically, so the pull path can't re-deliver.
137
150
  const idleCoordinators = meshCoordinators.filter(c => c.idle);
138
- if (idleCoordinators.length === 0) continue;
151
+ const generatingCoordinators = meshCoordinators.filter(c => !c.idle);
152
+ const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
153
+ const forceOnly = idleCoordinators.length === 0;
139
154
 
140
155
  // O(1) guard: skip the drain entirely when the queue is empty.
141
156
  if (store) {
@@ -146,16 +161,21 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
146
161
 
147
162
  let pendingEvents: PendingMeshCoordinatorEvent[] = [];
148
163
  try {
149
- pendingEvents = drainPendingMeshCoordinatorEvents(meshId, localDaemonId);
164
+ pendingEvents = drainPendingMeshCoordinatorEvents(
165
+ meshId,
166
+ localDaemonId,
167
+ forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
168
+ );
150
169
  } catch (e: any) {
151
170
  LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
152
171
  continue;
153
172
  }
154
173
  if (pendingEvents.length === 0) continue;
155
174
 
156
- LOG.info('MeshReconcile', `Reconcile inject: ${pendingEvents.length} pending event(s)${idleCoordinators.length} idle coordinator(s) for mesh ${meshId}`);
175
+ const mode = forceOnly ? 'force-draingenerating' : 'inject idle';
176
+ LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
157
177
  for (const pending of pendingEvents) {
158
- for (const c of idleCoordinators) {
178
+ for (const c of targetCoordinators) {
159
179
  injectPendingIntoCoordinator(c.instance, pending);
160
180
  }
161
181
  }
@@ -1241,16 +1241,41 @@ export class MeshRuntimeStore {
1241
1241
  return result.changes > 0;
1242
1242
  }
1243
1243
 
1244
- drainPendingEvents(meshId: string, coordinatorDaemonId?: string | null): Array<{ id: string; event: string; payload: unknown }> {
1244
+ /**
1245
+ * Drain undrained pending events for a mesh, atomically marking them drained.
1246
+ * When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
1247
+ * drained — the rest stay queued (drained=0) for a later drain. This is how the
1248
+ * reconcile loop force-drains terminal/force-inject events into a *generating*
1249
+ * coordinator while leaving non-force progress events for the coordinator's next
1250
+ * idle transition. Filtering happens inside the same transaction as the
1251
+ * drained=1 marking, so force-drain + a concurrent full drain can never both
1252
+ * consume the same row.
1253
+ */
1254
+ drainPendingEvents(
1255
+ meshId: string,
1256
+ coordinatorDaemonId?: string | null,
1257
+ opts?: { onlyEvents?: ReadonlySet<string> },
1258
+ ): Array<{ id: string; event: string; payload: unknown }> {
1245
1259
  return this.transaction(() => {
1246
- const whereClause = coordinatorDaemonId
1247
- ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)`
1248
- : `WHERE mesh_id = ? AND drained = 0`;
1249
- const params: unknown[] = coordinatorDaemonId
1250
- ? [meshId, coordinatorDaemonId]
1251
- : [meshId];
1260
+ const onlyEvents = opts?.onlyEvents;
1261
+ // An explicit-but-empty filter means "drain nothing" (no event name can match).
1262
+ if (onlyEvents && onlyEvents.size === 0) return [];
1263
+ const eventList = onlyEvents ? [...onlyEvents] : [];
1264
+ // Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
1265
+ // matching rows — a long run of non-force events ahead in the queue must not
1266
+ // crowd a force event out of the 100-row window.
1267
+ const clauses = ['mesh_id = ?', 'drained = 0'];
1268
+ const params: unknown[] = [meshId];
1269
+ if (coordinatorDaemonId) {
1270
+ clauses.push('(coordinator_daemon_id IS NULL OR coordinator_daemon_id = ?)');
1271
+ params.push(coordinatorDaemonId);
1272
+ }
1273
+ if (eventList.length > 0) {
1274
+ clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
1275
+ params.push(...eventList);
1276
+ }
1252
1277
  const rows = this.db.prepare(
1253
- `SELECT id, event, payload FROM mesh_pending_events ${whereClause} ORDER BY queued_at ASC LIMIT 100`
1278
+ `SELECT id, event, payload FROM mesh_pending_events WHERE ${clauses.join(' AND ')} ORDER BY queued_at ASC LIMIT 100`
1254
1279
  ).all(...params) as Array<{ id: string; event: string; payload: string }>;
1255
1280
  if (rows.length === 0) return [];
1256
1281
  const ids = rows.map(r => r.id);