@adhdev/daemon-core 0.9.82-rc.384 → 0.9.82-rc.385

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.
@@ -1,6 +1,6 @@
1
1
  export type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
2
2
  export { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents, } from './mesh-events-pending.js';
3
3
  export { reconcileDirectDispatchCompletionFromTranscript, } from './mesh-events-stale.js';
4
- export { setupMeshReconcileLoop, runMeshReconcileTick, } from './mesh-reconcile-loop.js';
4
+ export { setupMeshReconcileLoop, runMeshReconcileTick, resolveCoordinatorDrainDeliverability, shouldHoldPendingDrainForBusyLocalCoordinator, } from './mesh-reconcile-loop.js';
5
5
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
6
6
  export { tryAssignQueueTask, triggerMeshQueue, handleMeshForwardEvent, setupMeshEventForwarding, isMeshCoordinatorEvent, __resetIdleAutoFastForwardForTests, __resetMeshWorkspaceCacheForTests, } from './mesh-events-coordinator.js';
@@ -1,4 +1,60 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ /**
3
+ * DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
4
+ * queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
5
+ * pending coordinator events — i.e. whether there is a surface that will actually
6
+ * deliver them.
7
+ *
8
+ * Root cause being guarded: `get_pending_mesh_events` marks rows drained=1
9
+ * atomically and unconditionally. When the live CLI coordinator for the mesh is
10
+ * GENERATING (or modal-parked), the reconcile loop correctly HOLDS its terminal
11
+ * events (drained=0) for the coordinator's next idle tick — but a concurrent MCP
12
+ * poll draining the SAME queue consumes those held rows into a tool result that
13
+ * the busy coordinator never surfaces as a turn, so the completion is lost
14
+ * forever (drained=1, never re-queued). The reconcile loop is the authoritative
15
+ * delivery path for a live CLI coordinator; the MCP poll must defer to it.
16
+ *
17
+ * Returns:
18
+ * - hasLiveCliCoordinator: a CLI session with meshCoordinatorFor === meshId
19
+ * exists on this daemon (the reconcile loop owns its delivery).
20
+ * - deliverableNow: there is an IDLE live CLI coordinator (reconcile would
21
+ * full-drain into it) — draining now is safe and equivalent.
22
+ * - holdForReconcile: a live CLI coordinator exists but is non-idle
23
+ * (generating / modal-parked). The MCP poll MUST NOT drain; the reconcile
24
+ * loop holds the events undrained and injects them on the next idle tick.
25
+ *
26
+ * A mesh with NO live CLI coordinator on this daemon is a pure stdio MCP / LLM
27
+ * coordinator: the MCP tool result IS the only surface, so the poll legitimately
28
+ * drains (holdForReconcile=false). No regression to that path.
29
+ */
30
+ export declare function resolveCoordinatorDrainDeliverability(components: Pick<DaemonComponents, 'instanceManager'>, meshId: string): {
31
+ hasLiveCliCoordinator: boolean;
32
+ deliverableNow: boolean;
33
+ holdForReconcile: boolean;
34
+ };
35
+ /**
36
+ * DRAIN-WITHOUT-INJECT guard for the `get_pending_mesh_events` daemon handler.
37
+ *
38
+ * Decides whether an incoming pending-events DRAIN must be held (return nothing,
39
+ * leave rows drained=0) because the only surface for those events is a LOCAL live
40
+ * CLI coordinator that is currently busy (generating / modal-parked) — in which
41
+ * case the reconcile loop owns delivery on the coordinator's next idle tick, and
42
+ * the poll draining them now would lose them.
43
+ *
44
+ * The hold applies ONLY when BOTH:
45
+ * 1) a live CLI coordinator for this mesh on THIS daemon is non-idle, AND
46
+ * 2) the drain is targeted at THIS daemon (the requested coordinatorDaemonId is
47
+ * empty/broadcast, or matches one of this daemon's id forms).
48
+ *
49
+ * A REMOTE coordinator pulling our worker's events passes its own (remote)
50
+ * coordinatorDaemonId — condition (2) is false — so the drain proceeds and the
51
+ * remote pull is never blocked by our local coordinator's busy state. A pure
52
+ * stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
53
+ * result remains the surface and the drain proceeds. No regression to either.
54
+ */
55
+ export declare function shouldHoldPendingDrainForBusyLocalCoordinator(components: Pick<DaemonComponents, 'instanceManager'> & {
56
+ statusInstanceId?: string;
57
+ }, meshId: string, requestedCoordinatorDaemonId?: string | null): boolean;
2
58
  export declare function runMeshReconcileTick(components: DaemonComponents): Promise<void>;
3
59
  export declare function __resetUnresolvedForwardRejectionCountsForTests(): void;
4
60
  interface ReconcileLoopHandle {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.384",
3
+ "version": "0.9.82-rc.385",
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.384",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.385",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -10,6 +10,7 @@
10
10
  import {
11
11
  handleMeshForwardEvent,
12
12
  drainPendingMeshCoordinatorEvents,
13
+ shouldHoldPendingDrainForBusyLocalCoordinator,
13
14
  } from '../../mesh/mesh-events.js';
14
15
  import { normalizeInteractivePromptResponse } from '../../providers/types/interactive-prompt.js';
15
16
  import type { HighFamilyContext, HighFamilyHandler } from './types.js';
@@ -19,7 +20,7 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
19
20
  return handleMeshForwardEvent({ instanceManager: ctx.deps.instanceManager } as any, args as Record<string, unknown>);
20
21
  },
21
22
 
22
- get_pending_mesh_events: async (_ctx: HighFamilyContext, args: any) => {
23
+ get_pending_mesh_events: async (ctx: HighFamilyContext, args: any) => {
23
24
  const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
24
25
  // (B3) Respect coordinatorDaemonId when the caller declares it
25
26
  // so unicast events route to the right coordinator instead of
@@ -27,6 +28,17 @@ export const meshEventsHandlers: Record<string, HighFamilyHandler> = {
27
28
  const coordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
28
29
  ? args.coordinatorDaemonId.trim()
29
30
  : undefined;
31
+ // DRAIN-WITHOUT-INJECT guard: when a LOCAL live CLI coordinator for this mesh is
32
+ // busy (generating / modal-parked), the reconcile loop is HOLDING its terminal
33
+ // events (drained=0) for the coordinator's next idle tick. Draining here would
34
+ // consume those held rows (drained=1) into an MCP tool result the busy coordinator
35
+ // never surfaces as a turn — losing the completion forever. Defer to the reconcile
36
+ // loop: return nothing, leaving the rows undrained for its idle-tick delivery. A
37
+ // remote pull (foreign coordinatorDaemonId) or a pure stdio MCP coordinator (no live
38
+ // CLI session) is NOT held — see shouldHoldPendingDrainForBusyLocalCoordinator.
39
+ if (meshId && shouldHoldPendingDrainForBusyLocalCoordinator(ctx.deps, meshId, coordinatorDaemonId)) {
40
+ return { success: true, events: [], heldForBusyLocalCoordinator: true };
41
+ }
30
42
  const events = drainPendingMeshCoordinatorEvents(meshId || undefined, coordinatorDaemonId);
31
43
  return { success: true, events };
32
44
  },
@@ -20,6 +20,8 @@ export {
20
20
  export {
21
21
  setupMeshReconcileLoop,
22
22
  runMeshReconcileTick,
23
+ resolveCoordinatorDrainDeliverability,
24
+ shouldHoldPendingDrainForBusyLocalCoordinator,
23
25
  } from './mesh-reconcile-loop.js';
24
26
 
25
27
  export type { MeshQueueTriggerResult } from './mesh-events-coordinator.js';
@@ -248,6 +248,90 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
248
248
  return out;
249
249
  }
250
250
 
251
+ /**
252
+ * DRAIN-WITHOUT-INJECT guard. Classify, for a mesh on THIS daemon, whether a
253
+ * queue-drain caller (the MCP `get_pending_mesh_events` poll) may safely consume
254
+ * pending coordinator events — i.e. whether there is a surface that will actually
255
+ * deliver them.
256
+ *
257
+ * Root cause being guarded: `get_pending_mesh_events` marks rows drained=1
258
+ * atomically and unconditionally. When the live CLI coordinator for the mesh is
259
+ * GENERATING (or modal-parked), the reconcile loop correctly HOLDS its terminal
260
+ * events (drained=0) for the coordinator's next idle tick — but a concurrent MCP
261
+ * poll draining the SAME queue consumes those held rows into a tool result that
262
+ * the busy coordinator never surfaces as a turn, so the completion is lost
263
+ * forever (drained=1, never re-queued). The reconcile loop is the authoritative
264
+ * delivery path for a live CLI coordinator; the MCP poll must defer to it.
265
+ *
266
+ * Returns:
267
+ * - hasLiveCliCoordinator: a CLI session with meshCoordinatorFor === meshId
268
+ * exists on this daemon (the reconcile loop owns its delivery).
269
+ * - deliverableNow: there is an IDLE live CLI coordinator (reconcile would
270
+ * full-drain into it) — draining now is safe and equivalent.
271
+ * - holdForReconcile: a live CLI coordinator exists but is non-idle
272
+ * (generating / modal-parked). The MCP poll MUST NOT drain; the reconcile
273
+ * loop holds the events undrained and injects them on the next idle tick.
274
+ *
275
+ * A mesh with NO live CLI coordinator on this daemon is a pure stdio MCP / LLM
276
+ * coordinator: the MCP tool result IS the only surface, so the poll legitimately
277
+ * drains (holdForReconcile=false). No regression to that path.
278
+ */
279
+ export function resolveCoordinatorDrainDeliverability(
280
+ components: Pick<DaemonComponents, 'instanceManager'>,
281
+ meshId: string,
282
+ ): { hasLiveCliCoordinator: boolean; deliverableNow: boolean; holdForReconcile: boolean } {
283
+ const coordinators = findLiveCoordinators(components as DaemonComponents).filter(c => c.meshId === meshId);
284
+ if (coordinators.length === 0) {
285
+ return { hasLiveCliCoordinator: false, deliverableNow: false, holdForReconcile: false };
286
+ }
287
+ const hasIdle = coordinators.some(c => c.idle);
288
+ return {
289
+ hasLiveCliCoordinator: true,
290
+ deliverableNow: hasIdle,
291
+ // A live CLI coordinator exists but none is idle → the reconcile loop is
292
+ // holding the events; the poll must not steal them.
293
+ holdForReconcile: !hasIdle,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * DRAIN-WITHOUT-INJECT guard for the `get_pending_mesh_events` daemon handler.
299
+ *
300
+ * Decides whether an incoming pending-events DRAIN must be held (return nothing,
301
+ * leave rows drained=0) because the only surface for those events is a LOCAL live
302
+ * CLI coordinator that is currently busy (generating / modal-parked) — in which
303
+ * case the reconcile loop owns delivery on the coordinator's next idle tick, and
304
+ * the poll draining them now would lose them.
305
+ *
306
+ * The hold applies ONLY when BOTH:
307
+ * 1) a live CLI coordinator for this mesh on THIS daemon is non-idle, AND
308
+ * 2) the drain is targeted at THIS daemon (the requested coordinatorDaemonId is
309
+ * empty/broadcast, or matches one of this daemon's id forms).
310
+ *
311
+ * A REMOTE coordinator pulling our worker's events passes its own (remote)
312
+ * coordinatorDaemonId — condition (2) is false — so the drain proceeds and the
313
+ * remote pull is never blocked by our local coordinator's busy state. A pure
314
+ * stdio MCP coordinator (no live CLI session) never satisfies (1), so its tool
315
+ * result remains the surface and the drain proceeds. No regression to either.
316
+ */
317
+ export function shouldHoldPendingDrainForBusyLocalCoordinator(
318
+ components: Pick<DaemonComponents, 'instanceManager'> & { statusInstanceId?: string },
319
+ meshId: string,
320
+ requestedCoordinatorDaemonId?: string | null,
321
+ ): boolean {
322
+ if (!meshId) return false;
323
+ const deliverability = resolveCoordinatorDrainDeliverability(components, meshId);
324
+ if (!deliverability.holdForReconcile) return false;
325
+ // The local CLI coordinator is busy. Hold only when the drain is for THIS daemon.
326
+ const requested = readNonEmptyString(requestedCoordinatorDaemonId);
327
+ if (!requested) return true; // broadcast drain → would consume the held local events
328
+ const localIds = expandDaemonIdForms([
329
+ readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId),
330
+ readNonEmptyString(loadConfig().machineId),
331
+ ]);
332
+ return localIds.some(id => daemonIdsEquivalent(id, requested));
333
+ }
334
+
251
335
  // Inject a drained pending event into a live coordinator session. Force-inject
252
336
  // events carry force:true so they bypass the busy send-guard and land in the PTY
253
337
  // even while the coordinator is generating (see shouldForceInjectMeshEvent).