@adhdev/daemon-core 0.9.82-rc.354 → 0.9.82-rc.355

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,7 +1,7 @@
1
1
  import { existsSync } from 'fs';
2
2
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
3
  import { loadConfig } from '../config/config.js';
4
- import { getMesh, getMeshByRepo } from '../config/mesh-config.js';
4
+ import { getMesh, getMeshByRepo, listMeshes } from '../config/mesh-config.js';
5
5
  import { detectCLI } from '../detection/cli-detector.js';
6
6
  import { LOG } from '../logging/logger.js';
7
7
  import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
@@ -15,6 +15,7 @@ import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } f
15
15
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
16
16
  import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
17
17
  import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUnresolvedDelegateForward } from './mesh-unresolved-forward-outbox.js';
18
+ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
18
19
  import { getLastDisplayMessage } from '../status/snapshot.js';
19
20
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
20
21
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
@@ -70,6 +71,25 @@ function getCachedMeshByWorkspace(workspace: string): any {
70
71
  return mesh;
71
72
  }
72
73
 
74
+ // Deterministic meshId recovery for a forwarded worker event that carries no meshId.
75
+ // An unresolved-mesh worker (forwardUnresolvedDelegateEvent) cannot resolve its own
76
+ // meshId locally, so it pushes the event with nodeId + workspace only and relies on
77
+ // the coordinator — which hosts the mesh — to recover the id. Workspace recovery
78
+ // (getCachedMeshByWorkspace → getMeshByRepo) is the fast path but can miss (a worktree
79
+ // clone whose repoIdentity differs, or a transient cache state), which left the retry
80
+ // permanently rejected with "meshId required". The node-id IS a stable, coordinator-side
81
+ // fact: scan the hosted meshes for the one whose node matches the forwarded nodeId
82
+ // (3-form normalizer). This is timing-independent and never depends on repo lookup.
83
+ function recoverMeshIdByNodeId(nodeId: string): string {
84
+ if (!nodeId) return '';
85
+ for (const mesh of listMeshes()) {
86
+ if (Array.isArray(mesh.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, nodeId))) {
87
+ return readNonEmptyString(mesh.id);
88
+ }
89
+ }
90
+ return '';
91
+ }
92
+
73
93
  export function __resetIdleAutoFastForwardForTests(): void {
74
94
  idleAutoFastForwardLastAttempt.clear();
75
95
  }
@@ -1540,6 +1560,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1540
1560
  const eventSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
1541
1561
  const eventNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
1542
1562
 
1563
+ // EVTTRACE correlation context for this event's coordinator-side lifecycle (queue /
1564
+ // dedup / suppress). Observation only — never read by any decision below.
1565
+ const traceCtx = {
1566
+ taskId: args.metadataEvent.taskId,
1567
+ sessionId: eventSessionId,
1568
+ nodeId: eventNodeId,
1569
+ meshId: args.meshId,
1570
+ event: args.event,
1571
+ };
1572
+
1543
1573
  const sourceSession = args.sourceInstanceId
1544
1574
  ? components.instanceManager.getInstance(args.sourceInstanceId)
1545
1575
  : undefined;
@@ -1626,6 +1656,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1626
1656
  } catch { /* best-effort */ }
1627
1657
  }
1628
1658
  LOG.info('MeshEvents', `Suppressed ${args.event} for intentionally cleanup-stopped session ${eventSessionId || '(unknown session)'}`);
1659
+ traceMeshEventDrop('intentional_cleanup_stop', traceCtx);
1629
1660
  return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
1630
1661
  }
1631
1662
 
@@ -1647,6 +1678,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1647
1678
  }
1648
1679
  if (reconciledCompletion?.source === 'no_progress_terminal_ledger_suppression') {
1649
1680
  LOG.info('MeshEvents', `Suppressed no-progress monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1681
+ traceMeshEventDrop('no_progress_terminal_ledger_suppression', traceCtx, `terminalKind=${reconciledCompletion.terminalLedgerKind}`);
1650
1682
  return {
1651
1683
  success: true,
1652
1684
  forwarded: 0,
@@ -1659,6 +1691,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1659
1691
 
1660
1692
  if (isDuplicateRefineTerminalEvent(args.meshId, args.event, args.metadataEvent)) {
1661
1693
  LOG.info('MeshEvents', `Suppressed duplicate ${args.event} for refine job ${readRefineJobId({ metadataEvent: args.metadataEvent })}`);
1694
+ traceMeshEventDrop('duplicate_refine_terminal', traceCtx);
1662
1695
  return { success: true, forwarded: 0, suppressed: true, duplicateRefineTerminalEvent: true };
1663
1696
  }
1664
1697
 
@@ -1674,6 +1707,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1674
1707
  });
1675
1708
  if (duplicateApproval) {
1676
1709
  LOG.info('MeshEvents', `Suppressed duplicate approval event for mesh ${args.meshId} session ${eventSessionId}`);
1710
+ traceMeshEventDrop('duplicate_approval', traceCtx);
1677
1711
  return { success: true, forwarded: 0, suppressed: true, duplicateApproval: true };
1678
1712
  }
1679
1713
  }
@@ -1706,6 +1740,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1706
1740
  || args.metadataEvent.source === 'no_progress_reconciliation'
1707
1741
  ) {
1708
1742
  LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1743
+ traceMeshEventDrop('duplicate_completion_terminal_ledger', traceCtx);
1709
1744
  return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true };
1710
1745
  }
1711
1746
  }
@@ -1724,6 +1759,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1724
1759
  });
1725
1760
  if (duplicateCompletion) {
1726
1761
  LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
1762
+ traceMeshEventDrop('duplicate_completion', traceCtx);
1727
1763
  return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true };
1728
1764
  }
1729
1765
  }
@@ -1742,6 +1778,7 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1742
1778
  });
1743
1779
  if (duplicateStopped) {
1744
1780
  LOG.info('MeshEvents', `Suppressed duplicate stopped event for mesh ${args.meshId} session ${eventSessionId}`);
1781
+ traceMeshEventDrop('duplicate_stopped', traceCtx);
1745
1782
  return { success: true, forwarded: 0, suppressed: true, duplicateStopped: true };
1746
1783
  }
1747
1784
  }
@@ -1762,7 +1799,9 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1762
1799
  // genuine evidence, is marked terminal as before.
1763
1800
  const leaveDirectDispatchActive = !task && opts?.tentativeIfDirect === true;
1764
1801
  if (!leaveDirectDispatchActive) {
1765
- updateDirectDispatchStatus(args.meshId, sessionId, outcome);
1802
+ // CANON-B: flip the exact dispatch row the completion echoed its taskId for; the
1803
+ // session_id fallback (no echoed taskId) still covers legacy/relayed workers.
1804
+ updateDirectDispatchStatus(args.meshId, sessionId, outcome, eventTaskId);
1766
1805
  }
1767
1806
  markSessionDeliveriesTerminal(args.meshId, sessionId, outcome);
1768
1807
  setImmediate(() => cleanupTerminalDirectDispatches());
@@ -1779,7 +1818,10 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1779
1818
  const providerType = readNonEmptyString(args.metadataEvent.providerType);
1780
1819
 
1781
1820
  if (sessionId) {
1782
- directDispatchTaskIdForLedger = resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
1821
+ // CANON-B: trust the taskId the completion echoed; only fall back to the
1822
+ // most-recent-by-session heuristic when the worker carried none.
1823
+ directDispatchTaskIdForLedger = readNonEmptyString(args.metadataEvent.taskId)
1824
+ || resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
1783
1825
  // A false-idle completion of a direct dispatch is recorded but kept tentative (the
1784
1826
  // dispatch row stays active for the reconcile fallback); a genuine completion is terminal.
1785
1827
  const isFalseIdle = isFalseIdleCompletion(args.metadataEvent);
@@ -1867,12 +1909,21 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1867
1909
  } catch { /* best-effort */ }
1868
1910
  }
1869
1911
  if (sessionId) {
1870
- updateDirectDispatchStatus(args.meshId, sessionId, 'acked');
1871
- const activeDeliveries = ((): { id: string }[] => {
1912
+ // CANON-B: a generating_started that echoes its taskId acks exactly the dispatch
1913
+ // and the delivery for THAT task not every in-flight dispatch/delivery on the
1914
+ // session. A session that already holds a freshly-dispatched (still 'dispatched')
1915
+ // sibling must keep that row 'dispatched' so its own confirm can match it; acking
1916
+ // by session would mark it 'acked' prematurely and hide a genuine non-delivery.
1917
+ const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
1918
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
1919
+ const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
1872
1920
  try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
1873
1921
  catch { return []; }
1874
1922
  })();
1875
- for (const d of activeDeliveries) {
1923
+ const deliveriesToAck = startedTaskId
1924
+ ? activeDeliveries.filter(d => d.taskId === startedTaskId)
1925
+ : activeDeliveries;
1926
+ for (const d of deliveriesToAck) {
1876
1927
  updateSessionDeliveryStatus(d.id, 'acked');
1877
1928
  }
1878
1929
  }
@@ -1885,7 +1936,9 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1885
1936
  } catch { /* best-effort */ }
1886
1937
  }
1887
1938
  if (sessionId) {
1888
- directDispatchTaskIdForLedger = resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
1939
+ // CANON-B: prefer the echoed taskId; session heuristic is the fallback.
1940
+ directDispatchTaskIdForLedger = readNonEmptyString(args.metadataEvent.taskId)
1941
+ || resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
1889
1942
  completedTaskForLedger = markSessionTerminal(sessionId, 'failed');
1890
1943
  }
1891
1944
  }
@@ -2081,6 +2134,11 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
2081
2134
  };
2082
2135
  if (queuePendingMeshCoordinatorEvent(pendingEvent)) {
2083
2136
  LOG.info('MeshEvents', `Queued ${args.event} for coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''}${workerCoordinatorSessionId ? `, coordinator session ${workerCoordinatorSessionId}` : ''})`);
2137
+ // EVTTRACE: event persisted to the coordinator pending queue (awaiting reconcile drain).
2138
+ traceMeshEventStage('queued', traceCtx, workerCoordinatorDaemonId ? `coordinatorDaemon=${workerCoordinatorDaemonId}` : 'broadcast');
2139
+ } else {
2140
+ // EVTTRACE: queue rejected the event (dedup at queue time / persistence guard).
2141
+ traceMeshEventDrop('queue_dedup', traceCtx);
2084
2142
  }
2085
2143
  return { success: true, forwarded: 0 };
2086
2144
  }
@@ -2094,12 +2152,37 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
2094
2152
  const workspace = readNonEmptyString(payload.workspace);
2095
2153
 
2096
2154
  // The fallback worker-forward path (forwardUnresolvedDelegateEvent) cannot resolve a
2097
- // mesh id locally on the remote worker, so it forwards the event with workspace only.
2098
- // The coordinator hosting the mesh CAN resolve it: recover the mesh id by workspace
2099
- // when the payload doesn't carry one.
2155
+ // mesh id locally on the remote worker, so it forwards the event with nodeId +
2156
+ // workspace only. The coordinator hosting the mesh CAN resolve it. Two recovery
2157
+ // paths, in order:
2158
+ // 1) workspace → mesh (fast path; cached repoIdentity lookup), then
2159
+ // 2) nodeId → mesh (deterministic backstop; scans hosted meshes for the node).
2160
+ // Workspace recovery alone was unreliable — a worktree clone whose repoIdentity
2161
+ // differs, or a transient cache miss, left the reconcile retry permanently rejected
2162
+ // ("meshId required") so the worker's completion never surfaced to the coordinator.
2163
+ // The nodeId is a stable coordinator-side fact and resolves timing-independently.
2100
2164
  const meshId = readNonEmptyString(payload.meshId)
2101
- || (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '');
2102
- if (!meshId) return { success: false, error: 'meshId required' };
2165
+ || (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '')
2166
+ || recoverMeshIdByNodeId(nodeId);
2167
+ if (!meshId) {
2168
+ // EVTTRACE: forwarded event rejected at receive — no meshId could be resolved
2169
+ // (no payload.meshId, no workspace→mesh, no nodeId→mesh). Observation only.
2170
+ traceMeshEventDrop('meshId_required', {
2171
+ taskId: payload.taskId,
2172
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2173
+ nodeId,
2174
+ event: eventName,
2175
+ }, workspace ? `workspace=${workspace} unresolved` : 'no workspace/nodeId');
2176
+ return { success: false, error: 'meshId required' };
2177
+ }
2178
+ // EVTTRACE: forwarded event accepted at receive (meshId resolved).
2179
+ traceMeshEventStage('received', {
2180
+ taskId: payload.taskId,
2181
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2182
+ nodeId,
2183
+ meshId,
2184
+ event: eventName,
2185
+ });
2103
2186
  const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
2104
2187
  const relayModalMessage = readNonEmptyString(payload.modalMessage);
2105
2188
  const relayModalButtons = Array.isArray(payload.modalButtons)
@@ -2235,13 +2318,24 @@ function forwardUnresolvedDelegateEvent(
2235
2318
  // push below (degrades to the old at-most-once behaviour rather than dropping
2236
2319
  // the chance entirely).
2237
2320
  const persisted = enqueueUnresolvedDelegateForward(coordinatorDaemonId, eventName, payload);
2321
+ // EVTTRACE: unresolved-mesh worker persisted its completion to the outbox (no meshId
2322
+ // available locally; coordinator will recover it on receive).
2323
+ const fwdTraceCtx = {
2324
+ taskId: (payload as Record<string, unknown>).taskId,
2325
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2326
+ nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId),
2327
+ event: eventName,
2328
+ };
2329
+ traceMeshEventStage('outbox_enqueue', fwdTraceCtx, `coordinatorDaemon=${coordinatorDaemonId} meshId=absent`);
2238
2330
 
2239
2331
  // 2) Best-effort immediate push for low latency. On success, ack the outbox row so
2240
2332
  // the retry loop won't re-send it. On failure, leave it queued — PHASE 0 retries.
2333
+ traceMeshEventStage('forward_send', fwdTraceCtx, 'immediate push');
2241
2334
  Promise.resolve(components.dispatchMeshCommand(coordinatorDaemonId, 'mesh_forward_event', payload))
2242
2335
  .then((result: any) => {
2243
2336
  if (result && result.success === false) {
2244
2337
  LOG.warn('MeshEvents', `Immediate forward of ${eventName} to coordinator ${coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued for retry`);
2338
+ traceMeshEventDrop('immediate_forward_rejected', fwdTraceCtx, readNonEmptyString(result.error) || 'no reason');
2245
2339
  return;
2246
2340
  }
2247
2341
  // Acked. Mark the durable copy delivered so the retry loop skips it.
@@ -2368,6 +2462,17 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
2368
2462
  // silently. Leave a fail-loud diagnostic so the missing completion is traceable.
2369
2463
  // Benign non-delegate rejections (not_cli / no_workspace / etc.) are no-ops inside
2370
2464
  // recordUnroutableDelegateEvent.
2465
+ // EVTTRACE: a delegate event that could not be routed AND could not be
2466
+ // fallback-forwarded (no coordinator anchor). Only mesh_unresolved is a real
2467
+ // drop; the benign non-delegate rejections are ordinary non-mesh traffic.
2468
+ if (isUnroutableDelegateRejection(routing)) {
2469
+ traceMeshEventDrop('unroutable', {
2470
+ taskId: (event as Record<string, unknown>).meshActiveTaskId ?? (event as Record<string, unknown>).taskId,
2471
+ sessionId: routing.sessionId,
2472
+ nodeId: routing.nodeId,
2473
+ event: event.event,
2474
+ }, 'no coordinator anchor / mesh_unresolved');
2475
+ }
2371
2476
  recordUnroutableDelegateEvent(routing, event.event);
2372
2477
  return;
2373
2478
  }
@@ -88,6 +88,25 @@ function hasPendingRefineTerminalEventDuplicate(event: PendingMeshCoordinatorEve
88
88
  );
89
89
  }
90
90
 
91
+ // CANON-B / DUPNOTIF: terminal completion events that the coordinator surfaces as a
92
+ // notification. The native completion path (handleMeshCoordinatorEvent) and the transcript
93
+ // reconciliation fallback (reconcileDirectDispatchCompletionFromTranscript) BOTH queue one of
94
+ // these for the same finished task — with DIFFERENT timestamps — so a timestamp-bearing
95
+ // fingerprint lets both surface and the coordinator notifies twice. When the event carries a
96
+ // taskId we anchor the fingerprint on the taskId (dropping the timestamp), collapsing the two
97
+ // paths into a single surface. A weakness marker keeps a tentative false-idle completion
98
+ // distinct from the genuine completion that supersedes it, so the genuine one is never
99
+ // swallowed by the earlier weak one.
100
+ const TERMINAL_COMPLETION_EVENTS = new Set(['agent:generating_completed', 'agent:stopped']);
101
+
102
+ function isWeakCompletionMetadata(metadata: Record<string, unknown>): boolean {
103
+ const evidenceLevel = readNonEmptyString(metadata.evidenceLevel);
104
+ if (evidenceLevel === 'insufficient' || evidenceLevel === 'weak') return true;
105
+ if (metadata.reviewRecommended === true) return true;
106
+ const diag = readRecord(metadata.completionDiagnostic);
107
+ return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
108
+ }
109
+
91
110
  export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent): string {
92
111
  const metadata = readRecord(event.metadataEvent) || {};
93
112
  // Bootstrap events are node-scoped: dedup by meshId+event+nodeId only.
@@ -96,6 +115,20 @@ export function buildPendingEventFingerprint(event: PendingMeshCoordinatorEvent)
96
115
  if (event.event === 'worktree_bootstrap_complete' || event.event === 'worktree_bootstrap_failed') {
97
116
  return [event.meshId, event.event, event.nodeId || ''].join('::');
98
117
  }
118
+ // DUPNOTIF: a terminal completion carrying a taskId is deduped by taskId (+ weakness),
119
+ // NOT by timestamp — the native and transcript-reconciliation paths timestamp the same
120
+ // completion differently, and only taskId is stable across both.
121
+ if (TERMINAL_COMPLETION_EVENTS.has(event.event)) {
122
+ const terminalTaskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
123
+ if (terminalTaskId) {
124
+ return [
125
+ event.meshId,
126
+ event.event,
127
+ terminalTaskId,
128
+ isWeakCompletionMetadata(metadata) ? 'weak' : 'genuine',
129
+ ].join('::');
130
+ }
131
+ }
99
132
  const sessionId = resolveEventSessionId(metadata);
100
133
  const providerSessionId = readNonEmptyString(metadata.providerSessionId);
101
134
  const taskId = readNonEmptyString(metadata.taskId) || readNonEmptyString(readRecord(metadata.payload)?.taskId);
@@ -223,7 +223,9 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
223
223
  evidence,
224
224
  },
225
225
  });
226
- updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
226
+ // CANON-B: this reconcile path always knows the exact taskId — flip that dispatch row, not
227
+ // whichever sibling the session_id happens to match.
228
+ updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed', args.taskId);
227
229
  markSessionDeliveriesTerminal(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
228
230
  setImmediate(() => cleanupTerminalDirectDispatches());
229
231
  queuePendingMeshCoordinatorEvent({
@@ -56,6 +56,7 @@ import {
56
56
  expireStaleUnresolvedDelegateForwards,
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
58
  import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
59
+ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
59
60
  import { expandDaemonIdForms } from '@adhdev/mesh-shared';
60
61
  import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask } from './mesh-work-queue.js';
61
62
  import { readLedgerEntries } from './mesh-ledger.js';
@@ -230,6 +231,15 @@ function injectPendingIntoCoordinator(
230
231
  ): void {
231
232
  if (!coordinator || !pending.coordinatorMessage) return;
232
233
  const force = shouldForceInjectMeshEvent(pending.event);
234
+ // EVTTRACE: event surfaced to the coordinator (injected into its live CLI session).
235
+ // This is the terminal happy-path stage. Observation only.
236
+ traceMeshEventStage('surfaced', {
237
+ taskId: pending.metadataEvent?.taskId,
238
+ sessionId: pending.metadataEvent?.targetSessionId ?? pending.targetCoordinatorSessionId,
239
+ nodeId: pending.nodeId,
240
+ meshId: pending.meshId,
241
+ event: pending.event,
242
+ }, force ? 'force-inject' : 'inject');
233
243
  coordinator.onEvent('send_message', {
234
244
  input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
235
245
  ...(force ? { force: true } : {}),
@@ -357,6 +367,16 @@ function recoverStrandedAssignedDispatches(meshId: string, store: MeshRuntimeSto
357
367
  LOG.warn('MeshReconcile', `Reclaimed stranded assigned task ${row.id} on mesh ${meshId} `
358
368
  + `(node=${row.assignedNodeId ?? '?'} session=${row.assignedSessionId ?? '?'}, dispatched `
359
369
  + `${Math.round((nowMs - dispatchedAtMs) / 1000)}s ago, never confirmed delivered → ${reclaimed.status})`);
370
+ // EVTTRACE: the dispatch for this task was stranded (assigned, never confirmed
371
+ // delivered) and reclaimed (CANON-B) — its expected completion event never
372
+ // arrived. Observation only; the reclaim decision above is unchanged.
373
+ traceMeshEventDrop('assigned_stranded_reclaim', {
374
+ taskId: row.id,
375
+ sessionId: row.assignedSessionId,
376
+ nodeId: row.assignedNodeId,
377
+ meshId,
378
+ event: 'agent:generating_completed',
379
+ }, `unconfirmed ${Math.round((nowMs - dispatchedAtMs) / 1000)}s → ${reclaimed.status}`);
360
380
  }
361
381
  }
362
382
  }
@@ -652,6 +672,15 @@ function holdOrExpireStrictUnmatchedEvent(
652
672
  try {
653
673
  queuePendingMeshCoordinatorEvent(pending); // preserves queuedAt → true age retained
654
674
  LOG.info('MeshReconcile', `Strict route hold: coordinator session ${wantSession} not live on mesh ${meshId} — re-queued (${pending.event})`);
675
+ // EVTTRACE: event held (re-queued) — its originating coordinator session is not
676
+ // currently deliverable. Held, not dropped; surfaces later or expires past TTL.
677
+ traceMeshEventDrop('strict_route_hold', {
678
+ taskId: pending.metadataEvent?.taskId,
679
+ sessionId: pending.metadataEvent?.targetSessionId ?? wantSession,
680
+ nodeId: pending.nodeId,
681
+ meshId,
682
+ event: pending.event,
683
+ }, `coordinatorSession=${wantSession} not live`);
655
684
  } catch (e: any) {
656
685
  LOG.warn('MeshReconcile', `Strict route re-queue failed for ${pending.event} on mesh ${meshId}: ${e?.message || e}`);
657
686
  }
@@ -675,6 +704,14 @@ function holdOrExpireStrictUnmatchedEvent(
675
704
  },
676
705
  });
677
706
  LOG.warn('MeshReconcile', `Strict route expire: coordinator session ${wantSession} never returned for mesh ${meshId} — recorded to ledger (recoverable), dropped (${pending.event})`);
707
+ // EVTTRACE: event expired past the strict-route TTL — dropped (recoverable, ledgered).
708
+ traceMeshEventDrop('strict_route_expired', {
709
+ taskId: pending.metadataEvent?.taskId,
710
+ sessionId: pending.metadataEvent?.targetSessionId ?? wantSession,
711
+ nodeId: pending.nodeId,
712
+ meshId,
713
+ event: pending.event,
714
+ }, `coordinatorSession=${wantSession} never returned`);
678
715
  } catch (e: any) {
679
716
  LOG.warn('MeshReconcile', `Failed to ledger-expire strict-unmatched ${pending.event} for mesh ${meshId}: ${e?.message || e}`);
680
717
  }
@@ -698,16 +735,26 @@ async function retryUnresolvedDelegateForwards(components: DaemonComponents): Pr
698
735
  if (entries.length === 0) return;
699
736
 
700
737
  for (const entry of entries) {
738
+ // EVTTRACE correlation context for this outbox entry's retry.
739
+ const entryTraceCtx = {
740
+ taskId: (entry.payload as Record<string, unknown>).taskId,
741
+ sessionId: readNonEmptyString(entry.payload.targetSessionId) || readNonEmptyString(entry.payload.sessionId),
742
+ nodeId: readNonEmptyString(entry.payload.nodeId),
743
+ event: readNonEmptyString(entry.payload.event),
744
+ };
701
745
  let result: any;
702
746
  try {
747
+ traceMeshEventStage('forward_send', entryTraceCtx, `retry → ${entry.coordinatorDaemonId}`);
703
748
  result = await dispatchMeshCommand(entry.coordinatorDaemonId, 'mesh_forward_event', entry.payload);
704
749
  } catch (e: any) {
705
750
  // Coordinator unreachable — keep the entry queued and try again next tick.
706
751
  LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} failed: ${e?.message || e} — left queued`);
752
+ traceMeshEventDrop('retry_forward_failed', entryTraceCtx, e?.message || String(e));
707
753
  continue;
708
754
  }
709
755
  if (result && result.success === false) {
710
756
  LOG.warn('MeshReconcile', `Retry forward to coordinator ${entry.coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued`);
757
+ traceMeshEventDrop('retry_forward_rejected', entryTraceCtx, readNonEmptyString(result.error) || 'no reason');
711
758
  continue;
712
759
  }
713
760
  // Acked — mark the durable copy delivered.
@@ -887,9 +887,25 @@ export class MeshRuntimeStore {
887
887
  }));
888
888
  }
889
889
 
890
- updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void {
891
- if (!sessionId) return; // never update rows without a session binding
890
+ // CANON-B (dispatch identity): mesh_direct_dispatches is keyed by task_id (PK), but a
891
+ // single session can host several sequential direct dispatches (re-dispatch / nudge), so
892
+ // matching a status flip by session_id alone hits EVERY non-terminal row for that session
893
+ // — flipping a sibling task's row and stranding the one whose event actually fired (the
894
+ // assigned-stranded watchdog then requeues a task that is really still generating). When
895
+ // the firing event carries a taskId, target the single PK row; the session_id match is the
896
+ // legacy fallback only for events that arrive without a taskId.
897
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void {
892
898
  const now = new Date().toISOString();
899
+ if (taskId) {
900
+ this.db.prepare(`
901
+ UPDATE mesh_direct_dispatches
902
+ SET status = @status, updated_at = @updatedAt
903
+ WHERE mesh_id = @meshId AND task_id = @taskId
904
+ AND status NOT IN ('completed', 'failed')
905
+ `).run({ status, meshId, taskId, updatedAt: now });
906
+ return;
907
+ }
908
+ if (!sessionId) return; // never update rows without a session binding
893
909
  this.db.prepare(`
894
910
  UPDATE mesh_direct_dispatches
895
911
  SET status = @status, updated_at = @updatedAt
@@ -1140,9 +1140,16 @@ export function updateDirectDispatchStatus(
1140
1140
  meshId: string,
1141
1141
  sessionId: string,
1142
1142
  status: 'acked' | 'completed' | 'failed' | 'stale',
1143
+ taskId?: string,
1143
1144
  ): void {
1144
1145
  try {
1145
- MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
1146
+ // CANON-B: prefer the exact task_id row; fall back to the session_id match only when
1147
+ // the firing event carried no taskId (a legacy/relayed event). Warn on the fallback so
1148
+ // the residual PK-substitute path is observable when it strands a sibling dispatch.
1149
+ if (!taskId) {
1150
+ LOG.warn('MeshQueue', `updateDirectDispatchStatus(${status}) for mesh ${meshId} session ${sessionId} has no taskId — falling back to session_id match (may flip a sibling dispatch row)`);
1151
+ }
1152
+ MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status, taskId);
1146
1153
  } catch { /* best-effort */ }
1147
1154
  }
1148
1155
 
@@ -21,6 +21,7 @@ import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pt
21
21
  import { StatusMonitor } from './status-monitor.js';
22
22
  import { ChatHistoryWriter, isNativeSourceCanonicalHistory, materializeProviderNativeHistory, readChatHistory, readProviderChatHistory } from '../config/chat-history.js';
23
23
  import { LOG } from '../logging/logger.js';
24
+ import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
24
25
  import type { ChatMessage } from '../types.js';
25
26
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
26
27
  import { formatAutoApprovalMessage, pickApprovalButton, pickAutoApprovalButton, looksLikeActiveApprovalPromptText } from './approval-utils.js';
@@ -1411,6 +1412,26 @@ export class CliProviderInstance implements ProviderInstance {
1411
1412
  this.completedDebounceTimer = setTimeout(() => this.flushCompletedDebounceIfFinalized(), delayMs);
1412
1413
  }
1413
1414
 
1415
+ // EVTTRACE (observation-only): is this a mesh worker session whose completion
1416
+ // events must route to a coordinator? Used purely to gate trace logging so a
1417
+ // non-mesh CLI session's completions don't add EvtTrace noise. No decision logic.
1418
+ private isMeshWorkerSession(): boolean {
1419
+ return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId
1420
+ || this.settings.meshNodeId || this.settings.launchedByCoordinator);
1421
+ }
1422
+
1423
+ // EVTTRACE correlation context for this session's completion lifecycle. taskId is
1424
+ // the primary grep anchor; instanceId is the session fallback.
1425
+ private meshTraceCtx(event = 'agent:generating_completed'): Record<string, unknown> {
1426
+ return {
1427
+ taskId: this.settings.meshActiveTaskId,
1428
+ sessionId: this.instanceId,
1429
+ nodeId: this.settings.meshNodeId,
1430
+ meshId: this.settings.meshNodeFor,
1431
+ event,
1432
+ };
1433
+ }
1434
+
1414
1435
  private flushCompletedDebounceIfFinalized(): void {
1415
1436
  const pending = this.completedDebouncePending;
1416
1437
  if (!pending) {
@@ -1433,24 +1454,55 @@ export class CliProviderInstance implements ProviderInstance {
1433
1454
  if (block) {
1434
1455
  const blockReason = block.reason;
1435
1456
  const waitedMs = Date.now() - pending.firstObservedAt;
1436
- LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1437
- if ((block.terminal && !block.allowTimeout) || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS) {
1457
+ // CANON-C (completion-gate decouple): a block carrying `allowTimeout` is the
1458
+ // transcript-evidence gate the worker FSM has ALREADY reached idle and the only
1459
+ // thing missing is the append-only transcript's final assistant turn (a native-source
1460
+ // race: claude-cli owns its history externally and the file write trails the idle
1461
+ // transition). `allowTimeout` is set ONLY on the missing_final_assistant block, and
1462
+ // ONLY for mesh worker sessions (meshNodeFor / meshActiveTaskId / launchedByCoordinator).
1463
+ // The coordinator's sole path to learn this session is idle is agent:generating_completed,
1464
+ // so holding it up to COMPLETED_FINALIZATION_MAX_WAIT_MS (30s) leaves the coordinator
1465
+ // false-generating while the worker is done. Decouple the idle NOTIFICATION from the
1466
+ // transcript evidence: emit the completion immediately, marked weak
1467
+ // (completionDiagnostic.blockReason=missing_final_assistant, finalAssistantPresent=false).
1468
+ // The finalSummary is enriched on a SEPARATE path — the mesh reconcile loop reads the
1469
+ // transcript once written and re-emits a GENUINE completion (CANON-B weak→genuine
1470
+ // upgrade; buildPendingEventFingerprint keeps weak and genuine distinct so the enriched
1471
+ // one still surfaces, and isFalseIdleCompletion keeps the direct dispatch active until
1472
+ // then). All OTHER blocks (genuinely-busy adapter/partial/parsed states, transient
1473
+ // parse_error) keep the existing terminal-hold / 30s-retry behavior unchanged.
1474
+ const isTranscriptEvidenceGate = block.allowTimeout === true;
1475
+ LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
1476
+ if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
1438
1477
  if (pending.loggedBlockReason !== blockReason) {
1439
1478
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
1479
+ // EVTTRACE: completion held by the finalization gate (CANON-C). Observation
1480
+ // only — does not change the hold decision above.
1481
+ if (this.isMeshWorkerSession()) {
1482
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `${blockReason} waited=${waitedMs}ms`);
1483
+ }
1440
1484
  pending.loggedBlockReason = blockReason;
1441
1485
  }
1442
1486
  this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
1443
1487
  return;
1444
1488
  }
1489
+ const emittedAfterFinalizationTimeout = waitedMs >= COMPLETED_FINALIZATION_MAX_WAIT_MS;
1445
1490
  const completionDiagnostic = this.buildCompletedFinalizationDiagnostic({
1446
1491
  blockReason,
1447
1492
  latestStatus,
1448
1493
  latestVisibleStatus,
1449
1494
  waitedMs,
1450
1495
  pending,
1451
- emittedAfterFinalizationTimeout: true,
1496
+ emittedAfterFinalizationTimeout,
1452
1497
  });
1453
- LOG.warn('CLI', `[${this.type}] emitting completed event after ${waitedMs}ms without finalized assistant turn (${blockReason})`);
1498
+ // Surface the CANON-C immediate-emit path distinctly so a delegated worker's idle
1499
+ // notification (transcript still pending) is not mistaken for a 30s-timeout fallback.
1500
+ (completionDiagnostic as Record<string, unknown>).decoupledImmediateEmit = isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout;
1501
+ LOG.warn('CLI', `[${this.type}] emitting completed event (${isTranscriptEvidenceGate && !emittedAfterFinalizationTimeout ? 'CANON-C decoupled-immediate, transcript pending' : `after ${waitedMs}ms`}) without finalized assistant turn (${blockReason})`);
1502
+ // EVTTRACE: completion fired (forced past the finalization timeout / CANON-C decoupled-immediate).
1503
+ if (this.isMeshWorkerSession()) {
1504
+ traceMeshEventStage('fired', this.meshTraceCtx(), `forced after ${waitedMs}ms (${blockReason})`);
1505
+ }
1454
1506
  this.pushEvent({
1455
1507
  event: 'agent:generating_completed',
1456
1508
  chatTitle: pending.chatTitle,
@@ -1476,6 +1528,10 @@ export class CliProviderInstance implements ProviderInstance {
1476
1528
  }
1477
1529
 
1478
1530
  LOG.info('CLI', `[${this.type}] completed in ${pending.duration}s`);
1531
+ // EVTTRACE: completion fired (transcript finalized cleanly).
1532
+ if (this.isMeshWorkerSession()) {
1533
+ traceMeshEventStage('fired', this.meshTraceCtx(), `duration=${pending.duration}s`);
1534
+ }
1479
1535
  this.pushEvent({
1480
1536
  event: 'agent:generating_completed',
1481
1537
  chatTitle: pending.chatTitle,
@@ -1837,7 +1893,12 @@ export class CliProviderInstance implements ProviderInstance {
1837
1893
  LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1838
1894
  // completedDebouncePending intentionally left null — the session is now idle
1839
1895
  // with no confirmed turn, matching the startup-blip suppression semantics.
1896
+ // (No EvtTrace: not a mesh session, so nothing routes to a coordinator.)
1840
1897
  } else {
1898
+ // EVTTRACE: completion fired (short-generating idle path).
1899
+ if (this.isMeshWorkerSession()) {
1900
+ traceMeshEventStage('fired', this.meshTraceCtx(), `short-generating idle (source=${shortEvidenceSource})`);
1901
+ }
1841
1902
  this.pushEvent({
1842
1903
  event: 'agent:generating_completed',
1843
1904
  chatTitle,
@@ -1918,6 +1979,10 @@ export class CliProviderInstance implements ProviderInstance {
1918
1979
  && !this.hasAdapterPendingResponse()
1919
1980
  && !hasNonEmptyCliModalButtons(monitorParsedStatus?.activeModal ?? monitorParsedStatus?.modal)
1920
1981
  ) {
1982
+ // EVTTRACE: completion fired (no-progress monitor reconciled to completion).
1983
+ if (this.isMeshWorkerSession()) {
1984
+ traceMeshEventStage('fired', this.meshTraceCtx(), 'no_progress_monitor_final_summary');
1985
+ }
1921
1986
  this.pushEvent({
1922
1987
  event: 'agent:generating_completed',
1923
1988
  chatTitle,