@adhdev/daemon-core 0.9.82-rc.353 → 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.
Files changed (38) hide show
  1. package/dist/commands/handler.d.ts +15 -0
  2. package/dist/index.js +703 -220
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +703 -220
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  7. package/dist/mesh/mesh-runtime-store.d.ts +1 -1
  8. package/dist/mesh/mesh-work-queue.d.ts +1 -1
  9. package/dist/providers/acp-provider-instance.d.ts +3 -0
  10. package/dist/providers/cli-provider-instance.d.ts +14 -0
  11. package/dist/providers/manual-attendance.d.ts +63 -0
  12. package/dist/providers/provider-instance.d.ts +8 -0
  13. package/dist/providers/spec/adapter.d.ts +22 -0
  14. package/dist/providers/spec/fsm-driver.d.ts +49 -7
  15. package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
  16. package/dist/providers/spec/types.d.ts +9 -5
  17. package/package.json +2 -2
  18. package/src/commands/cli-manager.ts +20 -2
  19. package/src/commands/handler.ts +32 -0
  20. package/src/commands/router.ts +19 -6
  21. package/src/git/git-diff.ts +31 -14
  22. package/src/mesh/mesh-event-trace.ts +67 -0
  23. package/src/mesh/mesh-events-coordinator.ts +117 -12
  24. package/src/mesh/mesh-events-pending.ts +33 -0
  25. package/src/mesh/mesh-events-stale.ts +3 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +47 -0
  27. package/src/mesh/mesh-runtime-store.ts +18 -2
  28. package/src/mesh/mesh-work-queue.ts +8 -1
  29. package/src/providers/acp-provider-instance.ts +18 -1
  30. package/src/providers/cli-provider-instance.ts +123 -7
  31. package/src/providers/manual-attendance.ts +85 -0
  32. package/src/providers/provider-instance.ts +9 -0
  33. package/src/providers/spec/adapter.ts +67 -0
  34. package/src/providers/spec/cli-adapter.ts +6 -0
  35. package/src/providers/spec/evaluator.ts +24 -9
  36. package/src/providers/spec/fsm-driver.ts +135 -13
  37. package/src/providers/spec/fsm-evaluator.ts +19 -2
  38. package/src/providers/spec/types.ts +9 -5
@@ -0,0 +1,67 @@
1
+ /**
2
+ * EVTTRACE — observation-only lifecycle tracing for mesh completion events.
3
+ *
4
+ * Pure logging. This module adds NO decision logic: every call site is a bare log
5
+ * statement inserted ALONGSIDE (never replacing) the existing control flow. Its only
6
+ * job is to make a single completion event greppable across its whole lifecycle by a
7
+ * stable correlation key, and to mark — with one uniform anchor — every point where
8
+ * such an event is rejected / held / skipped / deduped.
9
+ *
10
+ * grep anchors:
11
+ * [EvtTrace] [stage:<name>] — lifecycle progressed a step (INFO)
12
+ * [EvtTrace] [drop:<reason>] — event did NOT advance here, with the reason (WARN)
13
+ *
14
+ * Follow one completion: grep the daemon log for its `task=<id>` (or `sess=<id>`)
15
+ * across the [stage:*] lines; the line with [drop:*] is where it died.
16
+ *
17
+ * Dependency-light on purpose (only the logger) so both providers/ and mesh/ can
18
+ * import it without any cycle risk.
19
+ */
20
+ import { LOG } from '../logging/logger.js';
21
+
22
+ const CAT = 'EvtTrace';
23
+
24
+ function s(v: unknown): string {
25
+ return typeof v === 'string' && v.trim() ? v.trim() : '';
26
+ }
27
+
28
+ export interface MeshEventTraceCtx {
29
+ /** Primary correlation anchor — the mesh task id (meshActiveTaskId / metadataEvent.taskId). */
30
+ taskId?: unknown;
31
+ /** Optional per-event id when the producer assigns one. */
32
+ eventId?: unknown;
33
+ /** Worker session id — the fallback anchor when no task is attached. */
34
+ sessionId?: unknown;
35
+ nodeId?: unknown;
36
+ meshId?: unknown;
37
+ event?: unknown;
38
+ }
39
+
40
+ /**
41
+ * Stable, greppable correlation key. `task=` and `sess=` are ALWAYS rendered (as `-`
42
+ * when absent) so the key shape is uniform across stages and a single grep alternation
43
+ * (`task=<id>\|sess=<id>`) follows the event end-to-end.
44
+ */
45
+ export function meshEventTraceKey(ctx: MeshEventTraceCtx): string {
46
+ const segs = [`task=${s(ctx.taskId) || '-'}`];
47
+ const eventId = s(ctx.eventId);
48
+ if (eventId) segs.push(`evt=${eventId}`);
49
+ segs.push(`sess=${s(ctx.sessionId) || '-'}`);
50
+ const nodeId = s(ctx.nodeId);
51
+ if (nodeId) segs.push(`node=${nodeId}`);
52
+ const meshId = s(ctx.meshId);
53
+ if (meshId) segs.push(`mesh=${meshId}`);
54
+ const event = s(ctx.event);
55
+ if (event) segs.push(`event=${event}`);
56
+ return segs.join(' ');
57
+ }
58
+
59
+ /** Lifecycle progress (INFO). One line per stage the event clears. */
60
+ export function traceMeshEventStage(stage: string, ctx: MeshEventTraceCtx, detail?: string): void {
61
+ LOG.info(CAT, `[stage:${stage}] ${meshEventTraceKey(ctx)}${detail ? ` — ${detail}` : ''}`);
62
+ }
63
+
64
+ /** Event did not advance — rejected / held / skipped / deduped (WARN). */
65
+ export function traceMeshEventDrop(reason: string, ctx: MeshEventTraceCtx, detail?: string): void {
66
+ LOG.warn(CAT, `[drop:${reason}] ${meshEventTraceKey(ctx)}${detail ? ` — ${detail}` : ''}`);
67
+ }
@@ -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
 
@@ -51,6 +51,7 @@ import { normalizeContent, flattenContent, normalizeInputEnvelope } from './cont
51
51
  import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport } from './provider-input-support.js';
52
52
  import type { ProviderInstance, ProviderState, AcpProviderState, ProviderErrorReason, ProviderEvent, InstanceContext, SessionModalState } from './provider-instance.js';
53
53
  import { StatusMonitor } from './status-monitor.js';
54
+ import { ManualAttendanceTracker } from './manual-attendance.js';
54
55
  import { buildLegacyModelModeSummaryMetadata } from './summary-metadata.js';
55
56
  import { workingDirBasename } from './working-dir.js';
56
57
  import {
@@ -845,7 +846,12 @@ export class AcpProviderInstance implements ProviderInstance {
845
846
  }
846
847
 
847
848
  // ─── Auto-approve: skip user confirmation ───
848
- if (this.settings.autoApprove !== false) {
849
+ // Held while a human is actively attending this session (manual
850
+ // attendance) so they can decide the permission themselves; falls
851
+ // through to the waiting_approval manual path below. A background
852
+ // worker is never attended, so its delegated auto-approve fires
853
+ // as before.
854
+ if (this.settings.autoApprove !== false && !this.manualAttendance.isAttended()) {
849
855
  const toolTitle = tc.title || tc.toolCallId || 'tool call';
850
856
  this.log.info(`[${this.type}] Auto-approving: ${toolTitle}`);
851
857
  this.appendSystemMessage(`Auto-approved: ${toolTitle}`);
@@ -1128,6 +1134,17 @@ export class AcpProviderInstance implements ProviderInstance {
1128
1134
 
1129
1135
  private permissionResolvers: ((approved: boolean) => void)[] = [];
1130
1136
 
1137
+ // Provider-common manual-attendance signal: while a human is actively driving
1138
+ // this session from the dashboard, auto-approve holds so they can decide on
1139
+ // the permission request themselves. Background workers are never attended →
1140
+ // delegated auto-approve is unaffected.
1141
+ private readonly manualAttendance = new ManualAttendanceTracker();
1142
+
1143
+ /** @see ProviderInstance.noteManualInteraction */
1144
+ noteManualInteraction(now = Date.now()): void {
1145
+ this.manualAttendance.note(now);
1146
+ }
1147
+
1131
1148
  async resolvePermission(approved: boolean): Promise<void> {
1132
1149
  const resolver = this.permissionResolvers.shift();
1133
1150
  if (resolver) {