@adhdev/daemon-core 0.9.82-rc.371 → 0.9.82-rc.373

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.
@@ -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 } from './mesh-events-coordinator.js';
52
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS, triggerMeshQueue, resolveForwardEventMeshId } from './mesh-events-coordinator.js';
53
53
  import {
54
54
  peekUnresolvedDelegateForwards,
55
55
  ackUnresolvedDelegateForward,
@@ -724,6 +724,22 @@ function holdOrExpireStrictUnmatchedEvent(
724
724
  // Stale entries (coordinator unreachable past the max age) are expired first so the
725
725
  // outbox can't grow without bound. The coordinator dedups duplicate deliveries on its
726
726
  // own fingerprint, so a retry that races the original immediate push is harmless.
727
+ // RECONCILE-MESHID-DROP: per-entry count of consecutive HARD rejections (the coordinator
728
+ // returned success:false, e.g. "meshId required"). A rejection means the push was delivered
729
+ // and deterministically refused — retrying the identical payload every 4s can never succeed,
730
+ // so it would loop until the 30-minute age expiry, spamming the log the whole time. After
731
+ // MAX_FORWARD_REJECTIONS such rejections we drop the entry (drain it) with ONE fail-loud
732
+ // warning. Transient transport failures (the dispatch throws — coordinator momentarily
733
+ // unreachable) do NOT count here; those legitimately retry until the age expiry. In-memory
734
+ // (keyed by the durable outbox row id) is sufficient: a daemon restart re-arms the loop, and
735
+ // the age expiry remains the durable backstop. Cleared whenever an entry is delivered/drained.
736
+ const unresolvedForwardRejectionCounts = new Map<string, number>();
737
+ const MAX_FORWARD_REJECTIONS = 5;
738
+
739
+ export function __resetUnresolvedForwardRejectionCountsForTests(): void {
740
+ unresolvedForwardRejectionCounts.clear();
741
+ }
742
+
727
743
  async function retryUnresolvedDelegateForwards(components: DaemonComponents): Promise<void> {
728
744
  const dispatchMeshCommand = components.dispatchMeshCommand;
729
745
  if (!dispatchMeshCommand) return;
@@ -732,7 +748,11 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
732
748
  expireStaleUnresolvedDelegateForwards();
733
749
 
734
750
  const entries = peekUnresolvedDelegateForwards();
735
- if (entries.length === 0) return;
751
+ if (entries.length === 0) {
752
+ // Nothing queued — clear any stale per-entry rejection counters so the map can't grow.
753
+ if (unresolvedForwardRejectionCounts.size > 0) unresolvedForwardRejectionCounts.clear();
754
+ return;
755
+ }
736
756
 
737
757
  // Every id-form THIS daemon answers to. A self-addressed outbox entry (coordinator
738
758
  // == this daemon) must never be cross-dialled — see the self-route branch below.
@@ -771,6 +791,7 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
771
791
  LOG.warn('MeshReconcile', `Local route of self-addressed forward to ${entry.coordinatorDaemonId} threw: ${e?.message || e} — draining anyway to break the retry loop`);
772
792
  }
773
793
  ackUnresolvedDelegateForward(entry.id);
794
+ unresolvedForwardRejectionCounts.delete(entry.id);
774
795
  if (localResult && localResult.success === false) {
775
796
  LOG.warn('MeshReconcile', `Self-addressed unresolved-delegate ${readNonEmptyString(entry.payload.event)} rejected by local router (${readNonEmptyString(localResult.error) || 'no reason'}) — drained to break the self-forward retry loop`);
776
797
  traceMeshEventDrop('self_forward_local_rejected', entryTraceCtx, readNonEmptyString(localResult.error) || 'no reason');
@@ -780,23 +801,54 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
780
801
  continue;
781
802
  }
782
803
 
804
+ // RECONCILE-MESHID-DROP: the stored forward payload was built when the worker
805
+ // "couldn't resolve" its meshId, so the coordinator rejects it "meshId required"
806
+ // when its own workspace/nodeId recovery misses. The worker can usually resolve it
807
+ // now (member node membership / live-session meshNodeFor) — stamp it on so the
808
+ // coordinator accepts. Covers entries persisted before this fix AND late-bound
809
+ // sessions. No-op when the payload already carries a meshId or none is resolvable.
810
+ let pushPayload = entry.payload;
811
+ if (!readNonEmptyString(pushPayload.meshId)) {
812
+ const recoveredMeshId = resolveForwardEventMeshId(components, pushPayload);
813
+ if (recoveredMeshId) {
814
+ pushPayload = { ...pushPayload, meshId: recoveredMeshId };
815
+ traceMeshEventStage('forward_meshid_recovered', entryTraceCtx, `meshId=${recoveredMeshId}`);
816
+ }
817
+ }
818
+
783
819
  let result: any;
784
820
  try {
785
821
  traceMeshEventStage('forward_send', entryTraceCtx, `retry → ${entry.coordinatorDaemonId}`);
786
- result = await dispatchMeshCommand(entry.coordinatorDaemonId, 'mesh_forward_event', entry.payload);
822
+ result = await dispatchMeshCommand(entry.coordinatorDaemonId, 'mesh_forward_event', pushPayload);
787
823
  } catch (e: any) {
788
- // Coordinator unreachable — keep the entry queued and try again next tick.
824
+ // Coordinator unreachable (transport threw) — keep the entry queued and try again
825
+ // next tick. This is NOT a hard rejection, so it does not count toward the cap;
826
+ // the age expiry bounds a permanently-offline coordinator.
789
827
  LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} failed: ${e?.message || e} — left queued`);
790
828
  traceMeshEventDrop('retry_forward_failed', entryTraceCtx, e?.message || String(e));
791
829
  continue;
792
830
  }
793
831
  if (result && result.success === false) {
794
- LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued`);
795
- traceMeshEventDrop('retry_forward_rejected', entryTraceCtx, readNonEmptyString(result.error) || 'no reason');
832
+ // Hard rejection: the push was delivered and deterministically refused. Retrying
833
+ // the identical payload can never succeed, so bound it — after MAX_FORWARD_REJECTIONS
834
+ // drop (drain) the entry with one fail-loud warning instead of re-spamming every tick.
835
+ const rejections = (unresolvedForwardRejectionCounts.get(entry.id) || 0) + 1;
836
+ unresolvedForwardRejectionCounts.set(entry.id, rejections);
837
+ const reason = readNonEmptyString(result.error) || 'no reason';
838
+ if (rejections >= MAX_FORWARD_REJECTIONS) {
839
+ ackUnresolvedDelegateForward(entry.id);
840
+ unresolvedForwardRejectionCounts.delete(entry.id);
841
+ LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} rejected ${rejections}x (${reason}) — dropping unresolved-delegate ${readNonEmptyString(entry.payload.event)} (sess=${readNonEmptyString(entry.payload.targetSessionId) || readNonEmptyString(entry.payload.sessionId) || '-'}) to stop the retry loop`);
842
+ traceMeshEventDrop('retry_forward_exhausted', entryTraceCtx, `${reason} (${rejections} rejections)`);
843
+ } else {
844
+ LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} rejected (${reason}) — left queued (attempt ${rejections}/${MAX_FORWARD_REJECTIONS})`);
845
+ traceMeshEventDrop('retry_forward_rejected', entryTraceCtx, reason);
846
+ }
796
847
  continue;
797
848
  }
798
849
  // Acked — mark the durable copy delivered.
799
850
  ackUnresolvedDelegateForward(entry.id);
851
+ unresolvedForwardRejectionCounts.delete(entry.id);
800
852
  LOG.info('MeshReconcile', `Retried+delivered unresolved-delegate ${readNonEmptyString(entry.payload.event)} to coordinator ${entry.coordinatorDaemonId}`);
801
853
  }
802
854
  }
@@ -609,7 +609,7 @@ export class MeshRuntimeStore {
609
609
  nodeId: string,
610
610
  sessionId: string,
611
611
  capabilityTags: string[] = [],
612
- opts?: { providerType?: string; providerMaxParallel?: number },
612
+ opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
613
613
  ): MeshWorkQueueEntry | null {
614
614
  return this.transaction(() => {
615
615
  this.ensureLegacyQueueMigrated(meshId);
@@ -690,9 +690,34 @@ export class MeshRuntimeStore {
690
690
  return !nodeBusy;
691
691
  };
692
692
 
693
+ // WTDISPATCH-FANOUT: a `convergence` task lands its work onto base (merge →
694
+ // push → cleanup against the real checkout). It must NEVER be claimed by a
695
+ // co-located worktree-clone session — N sibling worktree sessions on one daemon
696
+ // each claiming the same convergence intent is the 4-way push/deploy fan-out the
697
+ // live repro hit. Base-only, fail-closed: when the claiming node is a worktree
698
+ // (nodeIsWorktree), exclude every convergence candidate so it stays pending for
699
+ // the base node to pull.
700
+ const nodeIsWorktree = opts?.nodeIsWorktree === true;
701
+ const convergenceAllows = (candidate: MeshWorkQueueEntry): boolean =>
702
+ candidate.taskMode !== 'convergence' || !nodeIsWorktree;
703
+
704
+ // WTDISPATCH-FANOUT: defensive exact-target gate. The prioritized SQL above
705
+ // already segregates session/node-pinned rows, but a future query change (or a
706
+ // candidate row whose stored target drifted from its column) must never let a
707
+ // sibling worktree session on the same daemon absorb another node's/session's
708
+ // pinned task. When a task carries an explicit target, require an exact match
709
+ // here too — fail-closed.
710
+ const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
711
+ if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
712
+ if (candidate.targetNodeId && candidate.targetNodeId !== nodeId) return false;
713
+ return true;
714
+ };
715
+
693
716
  const entry = candidates.find(candidate =>
694
717
  nodeSatisfiesRequiredTags(candidate.requiredTags, capabilityTags)
695
718
  && dependenciesSatisfied(candidate)
719
+ && convergenceAllows(candidate)
720
+ && targetMatches(candidate)
696
721
  && nodeConflictAllows(candidate));
697
722
  if (!entry) return null;
698
723
 
@@ -721,7 +721,7 @@ export function claimNextTask(
721
721
  nodeId: string,
722
722
  sessionId: string,
723
723
  capabilityTags?: string[],
724
- opts?: { providerType?: string; providerMaxParallel?: number },
724
+ opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
725
725
  ): MeshWorkQueueEntry | null {
726
726
  return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags, opts);
727
727
  }