@adhdev/daemon-core 0.9.82-rc.365 → 0.9.82-rc.367

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.
@@ -19,7 +19,7 @@ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
19
19
  import { getLastDisplayMessage } from '../status/snapshot.js';
20
20
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
21
21
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
22
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, type MeshNodeIdentified } from '@adhdev/mesh-shared';
22
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, type MeshNodeIdentified } from '@adhdev/mesh-shared';
23
23
  import {
24
24
  findRecentTerminalLedgerEvidence,
25
25
  hasDispatchAfterTerminal,
@@ -457,16 +457,10 @@ function deliverTaskToSession(dispatchThunk: () => Promise<unknown>, ctx: Delive
457
457
  });
458
458
  }
459
459
 
460
- // WTCLAIM: normalize a workspace path for base-vs-worktree comparison. Mirrors
461
- // cli-manager.ts normalizeDirForCompare (fix-B) folds separator style, trailing
462
- // slashes, and case (Windows paths are case-insensitive) so a base node and a worktree
463
- // clone, whose only structural difference is their distinct workspace roots, are still
464
- // told apart. Kept local (the cli-manager copy is module-private) so the comparison rule
465
- // stays identical to the one fix-B already uses on the worker side.
466
- function normalizeMeshWorkspaceForCompare(dir?: string): string {
467
- if (typeof dir !== 'string') return '';
468
- return dir.trim().replace(/[\\/]+/g, '/').replace(/\/+$/, '').toLowerCase();
469
- }
460
+ // WTCLAIM: workspace normalization for base-vs-worktree comparison now lives in
461
+ // @adhdev/mesh-shared (normalizeMeshWorkspaceForCompare) so the enqueue→claim path,
462
+ // the mesh_status per-node session filter, and the read_chat node scope guard all
463
+ // share one comparison rule instead of drifting module-private copies.
470
464
 
471
465
  export function tryAssignQueueTask(
472
466
  components: DaemonComponents,
@@ -1325,6 +1319,14 @@ export interface MeshQueueTriggerResult {
1325
1319
  status?: string;
1326
1320
  }>;
1327
1321
  autoLaunchStarted: boolean;
1322
+ /**
1323
+ * True when a worker session is already on its way to claim a still-pending task —
1324
+ * either launched this tick (autoLaunchStarted) or launched on a prior tick and still
1325
+ * booting/awaiting-claim. Callers MUST treat this as "wait, do not launch another
1326
+ * session": a second launch double-edits the worktree. Mutually informative with
1327
+ * `noIdleMeshSessionAvailable`, which is suppressed whenever this is true.
1328
+ */
1329
+ autoLaunchPending?: boolean;
1328
1330
  noIdleMeshSessionAvailable?: boolean;
1329
1331
  }
1330
1332
 
@@ -1485,6 +1487,28 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1485
1487
  nodeId: task.assignedNodeId,
1486
1488
  sessionId: task.assignedSessionId,
1487
1489
  }));
1490
+
1491
+ // An auto-launch is "pending" when the coordinator has already spun a session up
1492
+ // for a still-pending task and is waiting on that session's idle→claim. This covers
1493
+ // two ticks:
1494
+ // - THIS tick fired the launch (autoLaunchStarted), or
1495
+ // - a PRIOR tick launched a session that is still booting/awaiting-claim — the
1496
+ // per-task await-claim guard (maybeAutoLaunchOneQueueSession) deliberately
1497
+ // declines to launch again, so autoLaunchStarted is false even though a session
1498
+ // is on its way to claim this task.
1499
+ // Without this signal, the second tick reports `noIdleMeshSessionAvailable` and the
1500
+ // MCP guidance tells the coordinator to launch ANOTHER worker — producing a duplicate
1501
+ // session that double-edits the worktree. The claim itself is fine; only the wording
1502
+ // was wrong, so we surface `autoLaunchPending` to suppress the bad "launch one more"
1503
+ // advice while the just-launched session converges.
1504
+ const autoLaunchPending = autoLaunchStarted || afterQueue.some(task => {
1505
+ if (task.status !== 'pending') return false;
1506
+ const al = task.autoLaunch;
1507
+ if (!al || (al.status !== 'started' && al.status !== 'completed')) return false;
1508
+ const launchedAtMs = Date.parse(al.updatedAt);
1509
+ return Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
1510
+ });
1511
+
1488
1512
  return {
1489
1513
  success: true,
1490
1514
  meshId,
@@ -1498,7 +1522,11 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1498
1522
  remoteIdleSessionsChecked,
1499
1523
  skippedSessions,
1500
1524
  autoLaunchStarted,
1501
- ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchStarted
1525
+ ...(autoLaunchPending ? { autoLaunchPending: true } : {}),
1526
+ // Only report "no idle session, go launch one" when nothing is already on its way.
1527
+ // A pending auto-launch (this tick or a prior still-converging one) means a session
1528
+ // WILL claim shortly, so it is not a no-session-available situation.
1529
+ ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchPending
1502
1530
  ? { noIdleMeshSessionAvailable: true }
1503
1531
  : {}),
1504
1532
  };
@@ -1810,7 +1838,26 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1810
1838
  // re-attributed to the latest task (the normal task_completed path below).
1811
1839
  const supersedesWeakTerminal = isWeakTerminalLedgerPayload(terminal.payload)
1812
1840
  && isGenuineCompletionEvidence(args.metadataEvent);
1813
- if (!newDispatchAfterTerminal && !supersedesWeakTerminal) {
1841
+ // CANON-B (direct-dispatch completion race): a FAST direct dispatch (mesh_send_task)
1842
+ // to an already-idle, previously-used session can have its genuine completion reach
1843
+ // this coordinator handler BEFORE the dispatching side records the new task's dispatch
1844
+ // row / task_dispatched ledger entry — insertDirectDispatch + appendLedgerEntry both run
1845
+ // AFTER the agent_command await resolves, while the worker may already be done. In that
1846
+ // window sessionHasActiveAssignment is false (no active dispatch row, no unterminal
1847
+ // ledger entry yet), so this prior-terminal dedup engages; and because providerSessionId
1848
+ // is STABLE across a reused session's turns, the providerSessionId/finalSummary match
1849
+ // below would suppress the NEW task's completion as a duplicate of the PRIOR task —
1850
+ // silently losing it (the observed intermittent miss; fresh enqueue/autoLaunch is immune
1851
+ // because a fresh session has no prior same-providerSessionId terminal and the queue row
1852
+ // is claimed atomically before dispatch). The echoed taskId is the authoritative
1853
+ // discriminator: when the completion names a DIFFERENT task than the recorded terminal,
1854
+ // it is a genuinely new task's completion, never a duplicate — let it through so it is
1855
+ // attributed to its own taskId. A same-task re-arrival (taskId equal) or a taskId-less
1856
+ // legacy event still falls through to the providerSessionId/finalSummary dedup.
1857
+ const terminalTaskId = readNonEmptyString(terminal.payload.taskId);
1858
+ const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
1859
+ const distinctTaskCompletion = !!eventTaskId && !!terminalTaskId && eventTaskId !== terminalTaskId;
1860
+ if (!newDispatchAfterTerminal && !supersedesWeakTerminal && !distinctTaskCompletion) {
1814
1861
  const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1815
1862
  const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1816
1863
  const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
@@ -2433,6 +2480,24 @@ function forwardUnresolvedDelegateEvent(
2433
2480
  workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
2434
2481
  };
2435
2482
 
2483
+ // Self-addressed fallback: the resolved coordinator IS this daemon (a self-
2484
+ // coordinating / single-node mesh, or a delegate whose coordinator anchor resolved
2485
+ // to our own id). A cross-daemon mesh_forward_event to our own id is REFUSED by the
2486
+ // dispatch self-dial guard ("route via the local router instead"), so persisting it
2487
+ // to the outbox would only loop forever in PHASE 0's retry, never acked. Honour the
2488
+ // guard's advice: route the event straight through the local receiver — the exact
2489
+ // path the coordinator runs on receiving a remote push — and skip the outbox entirely.
2490
+ const selfDaemonIds = resolveCoordinatorDrainDaemonIds(components);
2491
+ if (selfDaemonIds.some(self => daemonIdsEquivalent(self, coordinatorDaemonId))) {
2492
+ try {
2493
+ handleMeshForwardEvent(components, payload);
2494
+ LOG.info('MeshEvents', `Self-addressed unresolved-delegate ${eventName} routed via local router (coordinator ${coordinatorDaemonId} is self) — outbox skipped`);
2495
+ } catch (e: any) {
2496
+ LOG.warn('MeshEvents', `Local route of self-addressed unresolved-delegate ${eventName} failed: ${e?.message || e}`);
2497
+ }
2498
+ return true;
2499
+ }
2500
+
2436
2501
  // 1) Persist durably FIRST. Idempotent on fingerprint, so a re-fired completion
2437
2502
  // does not duplicate the outbox row. If persistence fails we still attempt the
2438
2503
  // push below (degrades to the old at-most-once behaviour rather than dropping
@@ -734,6 +734,12 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
734
734
  const entries = peekUnresolvedDelegateForwards();
735
735
  if (entries.length === 0) return;
736
736
 
737
+ // Every id-form THIS daemon answers to. A self-addressed outbox entry (coordinator
738
+ // == this daemon) must never be cross-dialled — see the self-route branch below.
739
+ const selfIds = resolveCoordinatorDaemonIds(components);
740
+ const isSelfCoordinatorId = (id: string): boolean =>
741
+ selfIds.some(self => daemonIdsEquivalent(self, id));
742
+
737
743
  for (const entry of entries) {
738
744
  // EVTTRACE correlation context for this outbox entry's retry.
739
745
  const entryTraceCtx = {
@@ -742,6 +748,38 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
742
748
  nodeId: readNonEmptyString(entry.payload.nodeId),
743
749
  event: readNonEmptyString(entry.payload.event),
744
750
  };
751
+
752
+ // Self-addressed forward: the coordinator daemon this entry targets IS this
753
+ // daemon (a self-coordinating / single-node mesh, or a delegate whose coordinator
754
+ // anchor resolved to our own id). A cross-daemon mesh_forward_event to our own id
755
+ // is REFUSED by the dispatch self-dial guard ("Refusing to send ... to this
756
+ // daemon's own id; route via the local router instead") on every retry, so the
757
+ // entry can never be acked and loops forever (~every tick), spamming the log and
758
+ // pinning the outbox row permanently undrained. Honour the guard's own advice:
759
+ // route the event straight through the local receiver (handleMeshForwardEvent —
760
+ // the same path the coordinator runs on receiving a remote push), then ack it.
761
+ // We drain regardless of the local result: a cross-daemon dispatch could not have
762
+ // resolved it either (the guard rejects before the receiver ever runs), so leaving
763
+ // it queued only re-spams. handleMeshForwardEvent has the BEST recovery chance —
764
+ // this daemon hosts the mesh, so its workspace/nodeId → meshId recovery applies.
765
+ if (isSelfCoordinatorId(entry.coordinatorDaemonId)) {
766
+ let localResult: any;
767
+ try {
768
+ traceMeshEventStage('forward_send', entryTraceCtx, `self → local router (${entry.coordinatorDaemonId})`);
769
+ localResult = handleMeshForwardEvent(components, entry.payload);
770
+ } catch (e: any) {
771
+ LOG.warn('MeshReconcile', `Local route of self-addressed forward to ${entry.coordinatorDaemonId} threw: ${e?.message || e} — draining anyway to break the retry loop`);
772
+ }
773
+ ackUnresolvedDelegateForward(entry.id);
774
+ if (localResult && localResult.success === false) {
775
+ 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
+ traceMeshEventDrop('self_forward_local_rejected', entryTraceCtx, readNonEmptyString(localResult.error) || 'no reason');
777
+ } else {
778
+ LOG.info('MeshReconcile', `Self-addressed unresolved-delegate ${readNonEmptyString(entry.payload.event)} routed via local router (coordinator ${entry.coordinatorDaemonId} is self) — drained`);
779
+ }
780
+ continue;
781
+ }
782
+
745
783
  let result: any;
746
784
  try {
747
785
  traceMeshEventStage('forward_send', entryTraceCtx, `retry → ${entry.coordinatorDaemonId}`);