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

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.382",
3
+ "version": "0.9.82-rc.384",
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.382",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.384",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -491,25 +491,51 @@ export class DaemonCommandRouter {
491
491
  * the wider plural-shape scan so a controlbar/modal command targeting a non-primary remote
492
492
  * session still resolves its owner — the singular readCachedInlineMeshActiveSessions semantics
493
493
  * other consumers depend on stay untouched.
494
+ *
495
+ * CANCEL-STOP-RELAY: the session-id cache scan above only matches when the coordinator's
496
+ * cached status snapshot already lists the worker's session id in a recognized active-sessions
497
+ * shape. A worktree-clone worker session whose id form/timing differs from the cached snapshot
498
+ * (or is simply not yet reflected) misses the scan, so a stop that carries the authoritative
499
+ * owning nodeId (mesh_queue_cancel knows assignedNodeId) used to silently fail to forward.
500
+ * `ownerNodeIdHint` adds a deterministic fallback: when the session-id scan misses, resolve the
501
+ * owner daemonId by matching the node by id (meshNodeIdMatches — same form-tolerant compare the
502
+ * rest of the router uses, no new raw compare). The same self-loopback guard applies to both
503
+ * paths, so a coordinator-hosted node is still never force-forwarded to a remote form of itself.
494
504
  */
495
- public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string): string | undefined {
505
+ public resolveRemoteMeshSessionOwnerDaemonId(sessionId: string, ownerNodeIdHint?: string): string | undefined {
496
506
  const trimmed = typeof sessionId === 'string' ? sessionId.trim() : '';
497
- if (!trimmed) return undefined;
507
+ const nodeHint = typeof ownerNodeIdHint === 'string' ? ownerNodeIdHint.trim() : '';
508
+ if (!trimmed && !nodeHint) return undefined;
498
509
  const selfDaemonId = this.deps.statusInstanceId;
499
- for (const node of this.collectMeshSessionOwnerCandidateNodes()) {
500
- if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
501
- const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
502
- // A matching node with no readable daemonId can't be attributed — keep scanning
503
- // the remaining candidates (e.g. the same session on an aggregate node that does
504
- // carry the daemonId) rather than bailing on the whole resolution.
505
- if (!nodeDaemonId) continue;
506
- // Only forward to a genuinely remote daemon. When the owning node is this
507
- // coordinator itself (locally hosted worker), fall through to local handling.
508
- // id-form robust: the node daemonId and selfDaemonId may be stored in different
509
- // forms of the same machine a strict `===` would miss the self-match and forward
510
- // a local session to a remote form of THIS daemon (loopback).
511
- if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
512
- return nodeDaemonId;
510
+ const candidates = this.collectMeshSessionOwnerCandidateNodes();
511
+ if (trimmed) {
512
+ for (const node of candidates) {
513
+ if (!collectMeshNodeHostedSessionIds(node).has(trimmed)) continue;
514
+ const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
515
+ // A matching node with no readable daemonId can't be attributed keep scanning
516
+ // the remaining candidates (e.g. the same session on an aggregate node that does
517
+ // carry the daemonId) rather than bailing on the whole resolution.
518
+ if (!nodeDaemonId) continue;
519
+ // Only forward to a genuinely remote daemon. When the owning node is this
520
+ // coordinator itself (locally hosted worker), fall through to local handling.
521
+ // id-form robust: the node daemonId and selfDaemonId may be stored in different
522
+ // forms of the same machine — a strict `===` would miss the self-match and forward
523
+ // a local session to a remote form of THIS daemon (loopback).
524
+ if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
525
+ return nodeDaemonId;
526
+ }
527
+ }
528
+ // Deterministic fallback: the session-id scan missed (cache lag / id-form mismatch on a
529
+ // worktree-clone worker), but the caller knows the authoritative owning nodeId. Resolve the
530
+ // owner daemonId straight off that node — never the fuzzy session cache.
531
+ if (nodeHint) {
532
+ for (const node of candidates) {
533
+ if (!meshNodeIdMatches(node, nodeHint)) continue;
534
+ const nodeDaemonId = readMeshNodeDaemonId(readObjectRecord(node));
535
+ if (!nodeDaemonId) continue;
536
+ if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
537
+ return nodeDaemonId;
538
+ }
513
539
  }
514
540
  return undefined;
515
541
  }
@@ -3092,7 +3118,14 @@ export class DaemonCommandRouter {
3092
3118
  const localInstance = this.deps.instanceManager?.getInstance(targetSessionId);
3093
3119
  const localRegistry = this.deps.sessionRegistry?.get?.(targetSessionId);
3094
3120
  if (!localInstance && !localRegistry) {
3095
- const ownerDaemonId = this.resolveRemoteMeshSessionOwnerDaemonId(targetSessionId);
3121
+ // CANCEL-STOP-RELAY: pass the authoritative owning nodeId (when the caller
3122
+ // shipped one in meshContext, e.g. mesh_queue_cancel's assignedNodeId) as the
3123
+ // deterministic owner-resolution fallback. The session-id cache scan stays the
3124
+ // primary path; the hint only kicks in when that scan misses (worktree-clone
3125
+ // worker session not yet in / form-mismatched against the cached snapshot).
3126
+ const meshContext = readObjectRecord(args?.meshContext);
3127
+ const ownerNodeIdHint = readStringValue(meshContext.nodeId);
3128
+ const ownerDaemonId = this.resolveRemoteMeshSessionOwnerDaemonId(targetSessionId, ownerNodeIdHint);
3096
3129
  if (ownerDaemonId) {
3097
3130
  LOG.info('Mesh', `[Mesh] Forwarding session-scoped '${cmd}' for remote worker session ${targetSessionId.split('_')[0]} → daemon ${ownerDaemonId.slice(0, 12)}`);
3098
3131
  const forwarded = await this.deps.dispatchMeshCommand(ownerDaemonId, cmd, {
@@ -49,7 +49,7 @@ import { drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, bui
49
49
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
50
50
  import { appendLedgerEntry } from './mesh-ledger.js';
51
51
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
52
- import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS, triggerMeshQueue, resolveForwardEventMeshId } from './mesh-events-coordinator.js';
52
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, triggerMeshQueue, resolveForwardEventMeshId } from './mesh-events-coordinator.js';
53
53
  import {
54
54
  peekUnresolvedDelegateForwards,
55
55
  ackUnresolvedDelegateForward,
@@ -589,35 +589,53 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
589
589
  for (const [meshId, meshCoordinators] of byMesh) {
590
590
  // Drain the local queue scoped to this coordinator daemon and inject.
591
591
  // - If an idle coordinator exists, FULL-drain and deliver every event to it
592
- // (it can receive non-force progress events without deadlocking).
593
- // - If only GENERATING coordinators exist, force-drain ONLY the force-inject
594
- // events (completion/approval/stop/refine·bootstrap terminal) and force-inject
595
- // them so a coordinator parked in `generating` while awaiting that very event
596
- // is not deadlocked. Non-force progress events stay queued for the next idle
597
- // tick injecting them would be noise mid-generation. Both drains mark the
598
- // consumed rows drained=1 atomically, so the pull path can't re-deliver.
592
+ // (the idle input box accepts the prompt as a real next turn). The drain
593
+ // marks consumed rows drained=1 atomically, so the pull path can't re-deliver.
594
+ // - If only GENERATING coordinators exist (no idle target), we HOLD: leave the
595
+ // events queued (drained=0) for the coordinator's next idle/turn-end tick.
596
+ //
597
+ // NOTIF-SURFACE-LOCAL (false-idle hold): we used to force-inject terminal events
598
+ // (completion/approval/stop/refine·bootstrap) straight into a *generating*
599
+ // coordinator's PTY (forceSendMessage → atomic content+\r write), on the theory it
600
+ // bypassed the busy send-guard and broke the await-result deadlock. But a raw PTY
601
+ // write into a claude-cli that is mid-generation is NOT consumed as a new turn — the
602
+ // bytes land in the terminal input buffer and the LLM never reads them on its next
603
+ // turn. The `surfaced/force-inject` trace fired, the row was marked drained=1, and the
604
+ // genuine completion was lost forever (the exact same-daemon local-worktree miss: the
605
+ // coordinator's OWN session is generating at the moment its worker completes). The
606
+ // deadlock the force path guarded against does not actually require force: a
607
+ // coordinator that dispatched a task via mesh_send_task returns to idle when that
608
+ // tool call resolves (dispatch is fire-and-forget; the worker runs for minutes while
609
+ // the coordinator is idle/between turns), so the completion lands on the very next
610
+ // idle tick (≤ one reconcile interval). Holding the event undrained for that idle
611
+ // tick is therefore the single, reliable delivery — and it is the SAME skip-and-hold
612
+ // the modal-park branch below already uses. This also makes double-injection
613
+ // structurally impossible: there is exactly one delivery path (the idle full-drain),
614
+ // so we never need a surface-time fingerprint to dedup a force-write against a re-drain.
599
615
  const idleCoordinators = meshCoordinators.filter(c => c.idle);
600
- // A coordinator parked on a harness modal (waiting_choice / waiting_approval)
601
- // is non-idle, so it would otherwise be treated as a force-inject target. It
602
- // must NOT be: a force-inject writes raw keystrokes into the PTY, which the
603
- // modal's key handler consumes and silently resolves to a choice the user
604
- // never made. Force-inject is only safe into a coordinator parked in plain
605
- // `generating` (the deadlock the force path exists to break). So generating
606
- // targets are the non-idle, non-modal-parked coordinators.
616
+ // A coordinator parked on a harness modal (waiting_choice / waiting_approval) is
617
+ // non-idle; it is held under the modal-park branch (a force-inject into a modal would
618
+ // write raw keystrokes the modal key handler eats, silently selecting a choice the
619
+ // user never made). A plainly-generating coordinator (non-idle, non-modal-parked) is
620
+ // ALSO held now — for the false-idle reason above but separately, so the C1 ledger
621
+ // audit and the operator-facing skip log can name the right hold reason.
607
622
  const generatingCoordinators = meshCoordinators.filter(c => !c.idle && !c.modalParked);
608
623
  const modalParkedCoordinators = meshCoordinators.filter(c => !c.idle && c.modalParked);
609
- const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
610
- const forceOnly = idleCoordinators.length === 0;
611
-
612
- // ── modal-blocked short-circuit (MUST precede the drain) ──────────────────
613
- // When the ONLY coordinators for this mesh are modal-parked (no idle, no plain
614
- // generating target), there is nowhere safe to deliver. We skip-and-requeue:
615
- // by NOT draining we leave the events at drained=0 in the queue, so a later tick
616
- // (once the modal is resolved and the coordinator returns to idle/generating)
617
- // delivers them. This short-circuit MUST run BEFORE drainPendingMeshCoordinatorEvents
618
- // the drain marks rows drained=1 atomically, which would lose the events for a
619
- // coordinator that is only transiently blocked. (Note: generating is still
620
- // force-injected via generatingCoordinators we never block the deadlock-break.)
624
+ // Only an IDLE coordinator is a deliverable target. A generating coordinator's PTY
625
+ // does not consume an injected prompt as a turn, so it is held (not a target).
626
+ const targetCoordinators = idleCoordinators;
627
+
628
+ // ── no-idle-target short-circuit (MUST precede the drain) ─────────────────
629
+ // When there is no IDLE coordinator for this mesh — only generating and/or
630
+ // modal-parked ones there is nowhere a queued event can land as a real turn.
631
+ // We skip-and-hold: by NOT draining we leave the events at drained=0 in the queue,
632
+ // so a later tick (once a coordinator returns to idle) delivers them. This
633
+ // short-circuit MUST run BEFORE drainPendingMeshCoordinatorEvents the drain marks
634
+ // rows drained=1 atomically, which would lose the events for a coordinator that is
635
+ // only transiently busy (the false-idle local-worktree miss). Both the generating
636
+ // hold and the modal-park hold record a C1 ledger audit copy so a held completion's
637
+ // worker summary is recoverable even if the coordinator never returns or the pending
638
+ // file is later trimmed.
621
639
  if (targetCoordinators.length === 0) {
622
640
  if (modalParkedCoordinators.length > 0) {
623
641
  // ── orphan escape (MUST precede the blanket modal-park hold) ──────────
@@ -705,6 +723,30 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
705
723
  modalParkedCoordinators.length,
706
724
  );
707
725
  }
726
+ } else if (generatingCoordinators.length > 0) {
727
+ // ── generating hold (NOTIF-SURFACE-LOCAL false-idle fix) ─────────────
728
+ // The only coordinator(s) for this mesh are plainly generating (no idle, no
729
+ // modal). A raw force-write into a generating claude-cli PTY is not consumed
730
+ // as a turn, so we do NOT inject and do NOT drain — the events stay queued
731
+ // (drained=0) and the next tick that finds the coordinator idle full-drains
732
+ // them as real turns (the coordinator returns to idle when its current
733
+ // tool-call/turn resolves; a dispatched worker runs for minutes while the
734
+ // coordinator is idle, so this lands within one reconcile interval). C1: mirror
735
+ // any held terminal events into the ledger so a completion's worker summary is
736
+ // recoverable even before that idle tick. Idempotent per process; O(1)-gated.
737
+ let hasPending = true;
738
+ if (store) {
739
+ try { hasPending = store.pendingEventCount(meshId) > 0; } catch { /* peek below */ }
740
+ }
741
+ if (hasPending) {
742
+ LOG.info('MeshReconcile', `Reconcile skip → generating: holding pending event(s) for mesh ${meshId} (${generatingCoordinators.length} coordinator(s) busy; events left queued for the next idle tick)`);
743
+ recordHeldTerminalEventsToLedger(
744
+ meshId,
745
+ drainDaemonIds.length > 0 ? drainDaemonIds : (localDaemonId ? [localDaemonId] : []),
746
+ 'generating_no_idle_coordinator',
747
+ generatingCoordinators.length,
748
+ );
749
+ }
708
750
  }
709
751
  continue;
710
752
  }
@@ -716,12 +758,15 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
716
758
  } catch { /* fall through to drain */ }
717
759
  }
718
760
 
761
+ // An idle coordinator is present (targetCoordinators.length > 0): FULL-drain every
762
+ // queued event and deliver it to the idle input box as a real turn. The no-idle case
763
+ // (generating/modal-only) was already held above and never reaches here, so there is
764
+ // no force-drain-into-generating path left — the single delivery is the idle drain.
719
765
  let pendingEvents: PendingMeshCoordinatorEvent[] = [];
720
766
  try {
721
767
  pendingEvents = drainPendingMeshCoordinatorEvents(
722
768
  meshId,
723
769
  drainDaemonIds.length > 0 ? drainDaemonIds : localDaemonId,
724
- forceOnly ? { onlyEvents: MESH_FORCE_INJECT_EVENTS } : undefined,
725
770
  );
726
771
  } catch (e: any) {
727
772
  LOG.warn('MeshReconcile', `Drain failed for mesh ${meshId}: ${e?.message || e}`);
@@ -729,8 +774,7 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
729
774
  }
730
775
  if (pendingEvents.length === 0) continue;
731
776
 
732
- const mode = forceOnly ? 'force-draingenerating' : 'inject idle';
733
- LOG.info('MeshReconcile', `Reconcile ${mode}: ${pendingEvents.length} pending event(s) → ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
777
+ LOG.info('MeshReconcile', `Reconcile inject idle: ${pendingEvents.length} pending event(s) ${targetCoordinators.length} coordinator(s) for mesh ${meshId}`);
734
778
  for (const pending of pendingEvents) {
735
779
  // Strict session routing (multi-coordinator): when the event names an
736
780
  // originating coordinator session, deliver ONLY to the live coordinator whose