@adhdev/daemon-core 0.9.82-rc.420 → 0.9.82-rc.421

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.
@@ -16,4 +16,26 @@ export declare function handleMeshForwardEvent(components: DaemonComponents, pay
16
16
  success: boolean;
17
17
  error: string;
18
18
  };
19
+ /**
20
+ * NOTIF-HELD-DRAIN (Fix 2): event-driven coordinator drain. The reconcile loop delivers a
21
+ * worker's queued completion to an IDLE local coordinator only on its periodic poll. When a
22
+ * coordinator is sitting idle awaiting exactly that completion, waiting up to a full poll
23
+ * interval is the avoidable delivery latency the RCA flags — and combined with the (now-fixed)
24
+ * modal-park false-positive it stretched into the multi-minute notification stall. So the
25
+ * MOMENT a worker delegate event is persisted for a mesh, attempt the same idle-coordinator
26
+ * drain immediately, mirroring the event-driven worker-claim path (agent:ready /
27
+ * agent:generating_completed → triggerMeshQueue).
28
+ *
29
+ * Safety:
30
+ * - drainPendingMeshCoordinatorEvents marks rows drained=1 atomically, so this races the
31
+ * reconcile poll and the coordinator's own idle auto-flush harmlessly — exactly one consumes
32
+ * each row.
33
+ * - Only IDLE, non-modal-parked coordinators are delivery targets (never a generating /
34
+ * consent-modal PTY).
35
+ * - Strict session routing is honoured: an event naming an originating coordinator session is
36
+ * delivered only to that live idle session; anything not currently deliverable here
37
+ * (wrong/absent session, or a message-less lifecycle event) is RE-QUEUED — never dropped —
38
+ * so the reconcile loop's strict hold/expire path remains the single authority for it.
39
+ */
40
+ export declare function flushPendingForMeshIdleCoordinators(components: DaemonComponents, meshId: string): void;
19
41
  export declare function setupMeshEventForwarding(components: DaemonComponents): void;
@@ -205,6 +205,17 @@ export declare class CliProviderInstance implements ProviderInstance {
205
205
  * absent from some of them.
206
206
  */
207
207
  resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null;
208
+ /**
209
+ * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
210
+ * of an autonomously-progressing mesh session rather than a genuine human-await modal —
211
+ * i.e. it is a mesh coordinator/worker session, a turn is actively in flight
212
+ * (hasAdapterPendingResponse), and no human is attending it by hand. Such a consent is
213
+ * driven to resolution by the harness/operator as part of the in-flight turn, so holding
214
+ * the mesh's completion events behind it (modal_parked) is the false-positive that stalls
215
+ * delivery. Narrow by design: manual attendance or a non-progressing session falls through
216
+ * to the genuine-modal classification.
217
+ */
218
+ private isTransientToolConsent;
208
219
  /** True when this session is parked on a modal awaiting a human answer. */
209
220
  isModalParked(): boolean;
210
221
  onEvent(event: string, data?: any): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.420",
3
+ "version": "0.9.82-rc.421",
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.420",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.421",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
package/src/index.ts CHANGED
@@ -293,7 +293,7 @@ export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
293
293
  // (finalSummary / workerResult.summary / lastMessagePreview). Same data the mobile
294
294
  // inbox is fed; reused by mesh_read_chat's cache fallback when the live P2P read path
295
295
  // is unavailable (saturated/unreachable peer).
296
- export { resolveMeshSurfacedSessionPreview, readMeshCompletionSummary } from './mesh/mesh-events-utils.js';
296
+ export { resolveMeshSurfacedSessionPreview, readMeshCompletionSummary, isWeakCompletionEvidence } from './mesh/mesh-events-utils.js';
297
297
 
298
298
  // ── Mesh Delivery Policy ──
299
299
  export { resolveDeliveryDecision, createSessionDelivery, updateSessionDeliveryStatus, getActiveSessionDeliveries, markSessionDeliveriesTerminal, recordCompletionConflict, getRecentCompletionConflicts } from './mesh/mesh-delivery-policy.js';
@@ -7,7 +7,8 @@ import type { SessionRecoveryContext } from './mesh-ledger.js';
7
7
  import { updateSessionTaskStatus, enqueueTask, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents, getQueue } from './mesh-work-queue.js';
8
8
  import { markSessionDeliveriesTerminal, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
9
9
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
10
- import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
10
+ import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents, type PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
11
+ import type { ProviderInstance } from '../providers/provider-instance.js';
11
12
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
12
13
  import { resolveMeshHostStatus } from './mesh-host-ownership.js';
13
14
  import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUnresolvedDelegateForward } from './mesh-unresolved-forward-outbox.js';
@@ -1670,6 +1671,93 @@ function ackUnresolvedDelegateForwardByFingerprint(
1670
1671
  if (match) ackUnresolvedDelegateForward(match.id);
1671
1672
  }
1672
1673
 
1674
+ /**
1675
+ * NOTIF-HELD-DRAIN (Fix 2): event-driven coordinator drain. The reconcile loop delivers a
1676
+ * worker's queued completion to an IDLE local coordinator only on its periodic poll. When a
1677
+ * coordinator is sitting idle awaiting exactly that completion, waiting up to a full poll
1678
+ * interval is the avoidable delivery latency the RCA flags — and combined with the (now-fixed)
1679
+ * modal-park false-positive it stretched into the multi-minute notification stall. So the
1680
+ * MOMENT a worker delegate event is persisted for a mesh, attempt the same idle-coordinator
1681
+ * drain immediately, mirroring the event-driven worker-claim path (agent:ready /
1682
+ * agent:generating_completed → triggerMeshQueue).
1683
+ *
1684
+ * Safety:
1685
+ * - drainPendingMeshCoordinatorEvents marks rows drained=1 atomically, so this races the
1686
+ * reconcile poll and the coordinator's own idle auto-flush harmlessly — exactly one consumes
1687
+ * each row.
1688
+ * - Only IDLE, non-modal-parked coordinators are delivery targets (never a generating /
1689
+ * consent-modal PTY).
1690
+ * - Strict session routing is honoured: an event naming an originating coordinator session is
1691
+ * delivered only to that live idle session; anything not currently deliverable here
1692
+ * (wrong/absent session, or a message-less lifecycle event) is RE-QUEUED — never dropped —
1693
+ * so the reconcile loop's strict hold/expire path remains the single authority for it.
1694
+ */
1695
+ export function flushPendingForMeshIdleCoordinators(components: DaemonComponents, meshId: string): void {
1696
+ // O(1) gate: skip the (relatively expensive) per-instance getState scan when the queue is
1697
+ // empty for this mesh.
1698
+ try {
1699
+ const store = MeshRuntimeStore.getInstance();
1700
+ if (store.pendingEventCount(meshId) === 0) return;
1701
+ } catch { /* store unavailable — fall through and let the drain decide */ }
1702
+
1703
+ const idleCoordinators: { instance: ProviderInstance; sessionId: string }[] = [];
1704
+ try {
1705
+ for (const inst of components.instanceManager.getByCategory('cli')) {
1706
+ const state = inst.getState();
1707
+ const settings = state.settings && typeof state.settings === 'object'
1708
+ ? state.settings as Record<string, unknown>
1709
+ : {};
1710
+ if (readNonEmptyString(settings.meshCoordinatorFor) !== meshId) continue;
1711
+ const status = readNonEmptyString(state.status).toLowerCase();
1712
+ const modalParked = typeof (inst as any).isModalParked === 'function'
1713
+ ? (inst as any).isModalParked() === true
1714
+ : (status === 'waiting_choice' || status === 'waiting_approval');
1715
+ if (status === 'idle' && !modalParked) {
1716
+ idleCoordinators.push({ instance: inst, sessionId: readNonEmptyString(state.instanceId) });
1717
+ }
1718
+ }
1719
+ } catch { return; }
1720
+ if (idleCoordinators.length === 0) return; // no idle target now → leave for the reconcile poll
1721
+
1722
+ const drainDaemonIds = resolveCoordinatorDrainDaemonIds(components);
1723
+ let pendingEvents: PendingMeshCoordinatorEvent[];
1724
+ try {
1725
+ pendingEvents = drainPendingMeshCoordinatorEvents(meshId, drainDaemonIds.length > 0 ? drainDaemonIds : undefined);
1726
+ } catch (e: any) {
1727
+ LOG.warn('MeshEvents', `Event-driven coordinator drain failed for mesh ${meshId}: ${e?.message || e}`);
1728
+ return;
1729
+ }
1730
+ if (pendingEvents.length === 0) return;
1731
+
1732
+ let delivered = 0;
1733
+ for (const pending of pendingEvents) {
1734
+ const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
1735
+ const targets = wantSession
1736
+ ? idleCoordinators.filter(c => c.sessionId === wantSession)
1737
+ : idleCoordinators;
1738
+ // Not deliverable into an idle target here (wrong/absent session), or a message-less
1739
+ // lifecycle event (agent:ready / generating_started carry no coordinatorMessage and
1740
+ // must not be injected): re-queue so the reconcile loop owns it (lazy-synth / strict
1741
+ // hold/expire). Re-queue preserves queuedAt so the strict TTL measures true age.
1742
+ if (targets.length === 0 || !pending.coordinatorMessage) {
1743
+ try { queuePendingMeshCoordinatorEvent(pending); } catch { /* best-effort re-queue */ }
1744
+ continue;
1745
+ }
1746
+ const message = pending.coordinatorMessage;
1747
+ const force = shouldForceInjectMeshEvent(pending.event);
1748
+ for (const c of targets) {
1749
+ c.instance.onEvent('send_message', {
1750
+ input: { text: message, textFallback: message },
1751
+ ...(force ? { force: true } : {}),
1752
+ });
1753
+ delivered++;
1754
+ }
1755
+ }
1756
+ if (delivered > 0) {
1757
+ LOG.info('MeshEvents', `Event-driven drain delivered ${delivered} pending event(s) to ${idleCoordinators.length} idle coordinator(s) for mesh ${meshId}`);
1758
+ }
1759
+ }
1760
+
1673
1761
  export function setupMeshEventForwarding(components: DaemonComponents) {
1674
1762
  components.instanceManager.onEvent((event) => {
1675
1763
  // --- Coordinator idle auto-flush (fast path) ---
@@ -1785,5 +1873,11 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1785
1873
  event: event.event,
1786
1874
  metadataEvent: event,
1787
1875
  });
1876
+
1877
+ // NOTIF-HELD-DRAIN (Fix 2): the worker's event is now persisted in the pending queue.
1878
+ // If a local coordinator for this mesh is sitting idle awaiting it, deliver immediately
1879
+ // instead of waiting up to a full reconcile interval (event-driven, mirrors the
1880
+ // worker-claim path). No-op when no idle coordinator is present (held for reconcile).
1881
+ flushPendingForMeshIdleCoordinators(components, routing.meshId);
1788
1882
  });
1789
1883
  }
@@ -307,9 +307,17 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
307
307
  const status = readNonEmptyString(state.status).toLowerCase();
308
308
  // getState() overlays the modal-park statuses: an active AskUserQuestion
309
309
  // prompt surfaces as waiting_choice, a tool-consent prompt as waiting_approval.
310
- // Lowercase literal compare the SessionStatus enum is forked across modules
311
- // and waiting_choice is absent from some of them (see cli-provider-instance).
312
- const modalParked = status === 'waiting_choice' || status === 'waiting_approval';
310
+ // NOTIF-HELD-DRAIN (Fix 1): consult the instance's own isModalParked() rather than the
311
+ // raw status literal so the corrected classification flows here — a busy mesh
312
+ // coordinator's routine, in-flight tool-consent (auto-approve off) is NOT a human-await
313
+ // modal and must NOT wedge the mesh's pending completion events under `modal_parked`.
314
+ // resolveModalParkStatus() (which isModalParked wraps) already encodes that distinction
315
+ // and the waiting_choice/stalled-auto-approve genuine-modal cases. Fall back to the
316
+ // status literal for any instance that does not expose the method. Lowercase compare —
317
+ // the SessionStatus enum is forked across modules and waiting_choice is absent from some.
318
+ const modalParked = typeof (inst as any).isModalParked === 'function'
319
+ ? (inst as any).isModalParked() === true
320
+ : (status === 'waiting_choice' || status === 'waiting_approval');
313
321
  const sessionId = readNonEmptyString(state.instanceId);
314
322
  // Modal-park transition observability: a coordinator entering modal-park is what
315
323
  // begins holding completion events under `modal_parked`; one leaving it is what
@@ -1134,11 +1134,44 @@ export class CliProviderInstance implements ProviderInstance {
1134
1134
  // as modal-parked so its events are held/surfaced rather than masked behind generating.
1135
1135
  if (adapterStatus.status === 'waiting_approval'
1136
1136
  && (!this.autoApproveEffectivelyActive(adapterStatus.status) || this.autoApproveMaskStalled())) {
1137
+ // NOTIF-HELD-DRAIN (Fix 1): an autonomous mesh session (coordinator or worker)
1138
+ // that is actively progressing a turn — a tool call is in flight
1139
+ // (hasAdapterPendingResponse) and NO human is attending it by hand — surfaces a
1140
+ // routine tool-consent `waiting_approval` on EVERY tool call when auto-approve is
1141
+ // off. That transient consent is part of the turn the harness/operator drives to
1142
+ // completion, NOT a session genuinely wedged awaiting a human's modal answer.
1143
+ // Classifying it modal-parked makes findLiveCoordinators hold the mesh's pending
1144
+ // completion events under `modal_parked` across a busy coordinator's whole work
1145
+ // batch, which is the multi-minute notification stall. Treat such a transient
1146
+ // consent as NOT modal-parked so it is held as ordinary "generating" (released on
1147
+ // the next idle) instead. A manually-attended session, a stalled auto-approve, or a
1148
+ // non-progressing session (no turn in flight) still parks — those are the genuine
1149
+ // human-await cases the guard must keep holding.
1150
+ if (this.isTransientToolConsent()) {
1151
+ return null;
1152
+ }
1137
1153
  return 'waiting_approval';
1138
1154
  }
1139
1155
  return null;
1140
1156
  }
1141
1157
 
1158
+ /**
1159
+ * NOTIF-HELD-DRAIN: true when this `waiting_approval` is a routine, transient tool-consent
1160
+ * of an autonomously-progressing mesh session rather than a genuine human-await modal —
1161
+ * i.e. it is a mesh coordinator/worker session, a turn is actively in flight
1162
+ * (hasAdapterPendingResponse), and no human is attending it by hand. Such a consent is
1163
+ * driven to resolution by the harness/operator as part of the in-flight turn, so holding
1164
+ * the mesh's completion events behind it (modal_parked) is the false-positive that stalls
1165
+ * delivery. Narrow by design: manual attendance or a non-progressing session falls through
1166
+ * to the genuine-modal classification.
1167
+ */
1168
+ private isTransientToolConsent(now = Date.now()): boolean {
1169
+ const isAutonomousMeshSession = this.isMeshWorkerSession() || !!this.settings.meshCoordinatorFor;
1170
+ return isAutonomousMeshSession
1171
+ && this.hasAdapterPendingResponse()
1172
+ && !this.manualAttendance.isAttended(now);
1173
+ }
1174
+
1142
1175
  /** True when this session is parked on a modal awaiting a human answer. */
1143
1176
  isModalParked(): boolean {
1144
1177
  return this.resolveModalParkStatus() !== null;