@adhdev/daemon-core 0.9.82-rc.366 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.366",
3
+ "version": "0.9.82-rc.367",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.366",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.367",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -30,6 +30,7 @@ import {
30
30
  import type { ChatMessage } from '../types.js';
31
31
  import type { SessionTransport } from '../shared-types.js';
32
32
  import { filterUserFacingChatMessages, isActivityChatMessage, isUserFacingChatMessage, normalizeChatMessages } from '../providers/chat-message-normalization.js';
33
+ import { normalizeMeshWorkspaceForCompare } from '@adhdev/mesh-shared';
33
34
 
34
35
  const RECENT_SEND_WINDOW_MS = 1200;
35
36
  export const READ_CHAT_PROVIDER_EVAL_TIMEOUT_MS = 25_000;
@@ -989,6 +990,53 @@ function normalizeComparableWorkspace(value: unknown): string {
989
990
  return path.resolve(text);
990
991
  }
991
992
 
993
+ /**
994
+ * read_chat node scope verdict. One physical daemon hosts a base node plus several
995
+ * worktree nodes; mesh_read_chat always dispatches read_chat with the requested
996
+ * node's workspace (`args.workspace`). When the resolved target session actually
997
+ * lives in a DIFFERENT worktree, returning its transcript — or worse, letting the
998
+ * native-history-by-workspace fallback splice sibling worktree turns into the
999
+ * reply — makes the coordinator believe one session received every worktree's
1000
+ * work. This guard refuses a CONFIRMED cross-workspace read instead of mixing.
1001
+ *
1002
+ * Conservative by design (mirrors the WTCLAIM fix-B "unknown → allow" rule): only
1003
+ * a session id that resolves to a known workspace which is unequal to a known
1004
+ * intended workspace blocks. When either side is unknown — no targetSessionId, no
1005
+ * args.workspace, an unregistered session, the coordinator self-session, or a
1006
+ * plain dashboard read that never passes a node workspace — the read proceeds
1007
+ * untouched, so base-node and same-daemon coordinator reads never regress.
1008
+ */
1009
+ export function evaluateReadChatNodeWorkspaceScope(args: {
1010
+ targetSessionId?: string;
1011
+ intendedWorkspace?: string;
1012
+ sessionWorkspace?: string;
1013
+ }): { scoped: false } | { scoped: true; intended: string; actual: string } {
1014
+ const targetSessionId = typeof args.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
1015
+ if (!targetSessionId) return { scoped: false };
1016
+ const intended = normalizeMeshWorkspaceForCompare(args.intendedWorkspace);
1017
+ const actual = normalizeMeshWorkspaceForCompare(args.sessionWorkspace);
1018
+ if (!intended || !actual) return { scoped: false };
1019
+ if (intended === actual) return { scoped: false };
1020
+ return { scoped: true, intended, actual };
1021
+ }
1022
+
1023
+ /**
1024
+ * Resolve the target session's ACTUAL workspace from the most authoritative source
1025
+ * available on this daemon: the session registry record (stamped at register
1026
+ * time), then the live CLI adapter's working directory, then the bound instance
1027
+ * state. Returns '' when nothing knows the session's workspace — the caller treats
1028
+ * that as "unknown" and does not block.
1029
+ */
1030
+ function resolveTargetSessionActualWorkspace(h: CommandHelpers, targetSessionId: string): string {
1031
+ const registryWorkspace = (h.ctx?.sessionRegistry?.get?.(targetSessionId) as any)?.workspace;
1032
+ if (typeof registryWorkspace === 'string' && registryWorkspace.trim()) return registryWorkspace;
1033
+ const adapter = h.getCliAdapter?.(targetSessionId);
1034
+ if (adapter && typeof adapter.workingDir === 'string' && adapter.workingDir.trim()) return adapter.workingDir;
1035
+ const instanceWorkspace = (getTargetInstance(h, { targetSessionId })?.getState?.() as any)?.workspace;
1036
+ if (typeof instanceWorkspace === 'string' && instanceWorkspace.trim()) return instanceWorkspace;
1037
+ return '';
1038
+ }
1039
+
992
1040
  function isCurrentRuntimePtySafelyAttributed(args: {
993
1041
  adapter: CliAdapter;
994
1042
  helpers: CommandHelpers;
@@ -2100,6 +2148,29 @@ export async function handleChatHistory(h: CommandHelpers, args: any): Promise<C
2100
2148
  }
2101
2149
 
2102
2150
  export async function handleReadChat(h: CommandHelpers, args: any): Promise<CommandResult> {
2151
+ // Node scope guard: a daemon hosting a base node + several worktree nodes must
2152
+ // not serve worktree A's transcript (or splice sibling worktree turns via the
2153
+ // native-history-by-workspace fallback) when a coordinator scoped the read to
2154
+ // worktree B. mesh_read_chat always passes the requested node's workspace as
2155
+ // args.workspace; refuse a CONFIRMED cross-workspace read rather than mix.
2156
+ {
2157
+ const guardSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
2158
+ if (guardSessionId && typeof args?.workspace === 'string' && args.workspace.trim()) {
2159
+ const verdict = evaluateReadChatNodeWorkspaceScope({
2160
+ targetSessionId: guardSessionId,
2161
+ intendedWorkspace: args.workspace,
2162
+ sessionWorkspace: resolveTargetSessionActualWorkspace(h, guardSessionId),
2163
+ });
2164
+ if (verdict.scoped) {
2165
+ LOG.info('Command', `[read_chat] node scope mismatch: session ${guardSessionId} workspace "${verdict.actual}" ≠ requested node workspace "${verdict.intended}" — refusing cross-worktree transcript`);
2166
+ return {
2167
+ success: false,
2168
+ code: 'read_chat_session_node_scope_mismatch',
2169
+ error: `Session ${guardSessionId} belongs to a different worktree (workspace "${verdict.actual}") than the requested node (workspace "${verdict.intended}"). Refusing to return a cross-worktree transcript — target the node that owns this session.`,
2170
+ };
2171
+ }
2172
+ }
2173
+ }
2103
2174
  // Resolve provider in order: explicit agentType/providerType > registered session.
2104
2175
  // Without this fallback, callers that only have a sessionId (e.g. a chat tail
2105
2176
  // controller that just got handed a session ID over WS) get an empty result
@@ -2649,10 +2720,33 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
2649
2720
  });
2650
2721
 
2651
2722
  if (supportsNative && !decision.nativeSelected) {
2723
+ // Dead-end: we are in the history-only path (no live PTY/ACP
2724
+ // adapter was found for this target session) AND provider-native
2725
+ // history is not safely mappable to the requested session
2726
+ // (no historySessionId stamp / workspace mismatch). Previously
2727
+ // this returned `success:false`, which the command logger emits
2728
+ // at warn level on EVERY poll (handler.ts logCommandEnd) —
2729
+ // mesh coordinators poll read_chat continuously, so a worker whose
2730
+ // transcript can never be safely mapped produced a 100% warn-log
2731
+ // storm with no recovery. Switch to a SOFT response: success with
2732
+ // empty messages + pending:true so the coordinator treats it as
2733
+ // "no live messages readable yet" rather than a hard failure, and
2734
+ // carry the machine-readable reason for debuggability. The normal
2735
+ // live-adapter path (above) and the safe-native return (below) are
2736
+ // unaffected — this is strictly the both-absent dead end.
2737
+ LOG.debug('Command', `[read_chat] soft pending: no live adapter and native history not safely mappable target=${String(args?.targetSessionId || '')} provider=${agentStr} reason=native_history_not_safely_available`);
2652
2738
  return {
2653
- success: false,
2739
+ success: true,
2740
+ pending: true,
2741
+ // Both signals are true here: we reached the history-only path
2742
+ // because no live adapter was found (`live_adapter_not_found`),
2743
+ // and native history is not safely mappable
2744
+ // (`native_history_not_safely_available`).
2745
+ reason: 'native_history_not_safely_available',
2746
+ reasons: ['live_adapter_not_found', 'native_history_not_safely_available'],
2654
2747
  code: 'native_history_not_safely_available',
2655
- error: 'Provider-native history was not safely available for the requested CLI session.',
2748
+ messages: [],
2749
+ status: 'idle',
2656
2750
  providerSessionId: historyProviderSessionId,
2657
2751
  messageSource: decision.messageSource,
2658
2752
  transcriptProvenance: decision.messageSource,
@@ -47,6 +47,7 @@ import {
47
47
  normalizeMeshNodeId,
48
48
  meshNodeIdMatches,
49
49
  daemonIdsEquivalent,
50
+ meshWorkspacesEquivalent,
50
51
  } from '@adhdev/mesh-shared';
51
52
  import { SessionRegistry } from '../sessions/registry.js';
52
53
  import { LOG } from '../logging/logger.js';
@@ -1130,6 +1131,21 @@ function readMeshTimeoutEnvMs(name: string, defaultMs: number): number {
1130
1131
  // (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
1131
1132
  export const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
1132
1133
  export const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
1134
+ // Cold-open warmup budget for the FIRST direct-peer probe to a peer whose mesh
1135
+ // DataChannel is not open yet. A fresh cross-machine, TURN-relayed handshake
1136
+ // (ICE gather + TURN allocation + DTLS across two residential networks) routinely
1137
+ // needs many seconds. Charging that warmup against the response deadline
1138
+ // (MESH_DIRECT_PROBE_TIMEOUT_MS) made the very first git_status to a cold peer
1139
+ // false-timeout, after which the warm retry — reusing the now-open channel —
1140
+ // succeeded: the classic cold-open signature. This budget bounds ONLY the
1141
+ // "channel not open yet" phase; once the channel opens the response deadline
1142
+ // governs the round trip. A genuine connect failure still rejects immediately —
1143
+ // the mesh manager fails the peer the instant its PeerConnection state goes
1144
+ // terminal, and isMeshConnectionDefinitivelyDown pre-gates an already-dead peer —
1145
+ // so this never masks a real failure for the whole window; it only grants a
1146
+ // still-handshaking peer the time it legitimately needs. Matches the daemon-cloud
1147
+ // DaemonMeshManager CONNECT_TIMEOUT_MS (45s). Env-overridable for very slow links.
1148
+ export const MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS', 45_000);
1133
1149
  // How long a successful per-peer git_status probe stays fresh enough to be
1134
1150
  // reused instead of issuing another blocking `refreshUpstream:true` fan-out.
1135
1151
  // A slow (TURN-relayed) peer's probe can take 9-23s, and the dashboard's
@@ -1198,17 +1214,120 @@ export class MeshGitProbeCache {
1198
1214
  }
1199
1215
  }
1200
1216
 
1217
+ /**
1218
+ * Await `work` under a warmup-aware deadline so a cold-open DataChannel handshake
1219
+ * is NOT charged against the command response budget — the root cause of the
1220
+ * "first mesh probe to a cold peer false-times-out, the warm retry succeeds"
1221
+ * signature. Two budgets, switched by the live peer connection state:
1222
+ *
1223
+ * - While `isConnected()` returns false the peer's channel is still opening; the
1224
+ * cold-open `connectTimeoutMs` budget applies. This phase is deliberately
1225
+ * generous because a TURN-relayed cross-machine handshake legitimately needs
1226
+ * many seconds — but a genuine connect *failure* is surfaced by `work`
1227
+ * rejecting on its own (the mesh manager fails the peer the instant its
1228
+ * PeerConnection state goes terminal), so a real failure is never masked for
1229
+ * the whole window.
1230
+ * - The first time `isConnected()` returns true the channel is warm; from that
1231
+ * instant the tight `responseTimeoutMs` governs how long the handler may take.
1232
+ * Warm-channel callers therefore see behavior identical to the old single
1233
+ * `Promise.race(work, responseTimeoutMs)`.
1234
+ *
1235
+ * Rejects with `Error('timeout')` when either budget is exhausted, mirroring the
1236
+ * previous single-race contract. Pure except for timers + the injected
1237
+ * `isConnected` probe, so it is unit-testable under fake timers without any real
1238
+ * WebRTC. When no connection getter is wired `isConnected` should be `() => true`
1239
+ * (the caller's choice) so the response deadline governs from t0 — the legacy
1240
+ * single-budget behavior, never a combined connect+response window.
1241
+ */
1242
+ export function awaitWithWarmupDeadline<T>(
1243
+ work: Promise<T>,
1244
+ opts: {
1245
+ isConnected: () => boolean;
1246
+ connectTimeoutMs: number;
1247
+ responseTimeoutMs: number;
1248
+ pollIntervalMs?: number;
1249
+ },
1250
+ ): Promise<T> {
1251
+ const pollMs = Math.max(1, Math.min(opts.pollIntervalMs ?? 200, opts.connectTimeoutMs));
1252
+ return new Promise<T>((resolve, reject) => {
1253
+ let done = false;
1254
+ let poll: ReturnType<typeof setInterval> | undefined;
1255
+ let responseTimer: ReturnType<typeof setTimeout> | undefined;
1256
+ const startedAt = Date.now();
1257
+ const cleanup = () => {
1258
+ if (poll) { clearInterval(poll); poll = undefined; }
1259
+ if (responseTimer) { clearTimeout(responseTimer); responseTimer = undefined; }
1260
+ };
1261
+ const settle = (fn: () => void) => {
1262
+ if (done) return;
1263
+ done = true;
1264
+ cleanup();
1265
+ fn();
1266
+ };
1267
+ // Arm the response deadline exactly once, the moment the channel is warm.
1268
+ const armResponse = () => {
1269
+ if (responseTimer || done) return;
1270
+ responseTimer = setTimeout(
1271
+ () => settle(() => reject(new Error('timeout'))),
1272
+ opts.responseTimeoutMs,
1273
+ );
1274
+ if (typeof responseTimer.unref === 'function') responseTimer.unref();
1275
+ };
1276
+ const onPoll = () => {
1277
+ if (done) return;
1278
+ if (opts.isConnected()) {
1279
+ if (poll) { clearInterval(poll); poll = undefined; }
1280
+ armResponse();
1281
+ return;
1282
+ }
1283
+ if (Date.now() - startedAt >= opts.connectTimeoutMs) {
1284
+ settle(() => reject(new Error('timeout')));
1285
+ }
1286
+ };
1287
+ if (opts.isConnected()) {
1288
+ // Already warm (e.g. a retry over an open channel) — skip the warmup
1289
+ // phase entirely and let the response deadline govern from t0.
1290
+ armResponse();
1291
+ } else {
1292
+ poll = setInterval(onPoll, pollMs);
1293
+ if (typeof poll.unref === 'function') poll.unref();
1294
+ }
1295
+ work.then(
1296
+ (val) => settle(() => resolve(val)),
1297
+ (err) => settle(() => reject(err)),
1298
+ );
1299
+ });
1300
+ }
1301
+
1201
1302
  async function probeRemoteMeshGitStatus(args: {
1202
1303
  dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
1203
1304
  daemonId: string;
1204
1305
  workspace: string;
1205
- timeoutMs: number;
1306
+ // Response deadline — applies only once the peer's DataChannel is open (warm).
1307
+ responseTimeoutMs: number;
1308
+ // Cold-open warmup budget — applies only while the channel is still opening.
1309
+ connectTimeoutMs: number;
1310
+ // Live peer connection snapshot getter; lets the deadline tell "still warming
1311
+ // up" apart from "warm but slow". Absent → behave as if always warm (the
1312
+ // response deadline governs from t0, i.e. the legacy single-budget behavior).
1313
+ getConnection?: (daemonId: string) => Record<string, unknown> | null;
1206
1314
  }): Promise<Record<string, unknown> | null> {
1207
1315
  if (!args.dispatchMeshCommand) return null;
1208
- const remoteResult = await Promise.race([
1209
- args.dispatchMeshCommand(args.daemonId, 'git_status', { workspace: args.workspace, refreshUpstream: true }),
1210
- new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), args.timeoutMs)),
1211
- ]) as any;
1316
+ // Fire the dispatch first — this is what drives the mesh manager to ensure /
1317
+ // open the peer connection. The warmup-aware deadline then charges the
1318
+ // cold-open handshake to the connect budget and only the warm round trip to
1319
+ // the response budget, so the first probe to a cold peer is no longer
1320
+ // false-timed-out before its channel has even opened.
1321
+ const dispatch = args.dispatchMeshCommand(args.daemonId, 'git_status', { workspace: args.workspace, refreshUpstream: true });
1322
+ const getConnection = args.getConnection;
1323
+ const isConnected = getConnection
1324
+ ? () => readMeshConnectionState(getConnection(args.daemonId)) === 'connected'
1325
+ : () => true;
1326
+ const remoteResult = await awaitWithWarmupDeadline(dispatch, {
1327
+ isConnected,
1328
+ connectTimeoutMs: args.connectTimeoutMs,
1329
+ responseTimeoutMs: args.responseTimeoutMs,
1330
+ }) as any;
1212
1331
  const remoteGit = remoteResult?.status ?? remoteResult?.git ?? remoteResult;
1213
1332
  if (!remoteGit || typeof remoteGit !== 'object' || typeof remoteGit.isGitRepo !== 'boolean') return null;
1214
1333
  // The member daemon stamps its own platform/arch onto the git_status result
@@ -1269,6 +1388,8 @@ export async function probeRemoteMeshGitStatusWithRetry(args: {
1269
1388
  timeoutMs: number;
1270
1389
  /** Per-attempt timeout for retries (attempts > 0); defaults to timeoutMs. */
1271
1390
  retryTimeoutMs?: number;
1391
+ /** Cold-open warmup budget per attempt; defaults to MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS. */
1392
+ connectTimeoutMs?: number;
1272
1393
  getConnection?: (daemonId: string) => Record<string, unknown> | null;
1273
1394
  onConnection?: (connection: Record<string, unknown>) => void;
1274
1395
  }): Promise<Record<string, unknown> | null> {
@@ -1300,7 +1421,9 @@ export async function probeRemoteMeshGitStatusWithRetry(args: {
1300
1421
  dispatchMeshCommand: args.dispatchMeshCommand,
1301
1422
  daemonId: args.daemonId,
1302
1423
  workspace: args.workspace,
1303
- timeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
1424
+ responseTimeoutMs: attempt === 0 ? args.timeoutMs : (args.retryTimeoutMs ?? args.timeoutMs),
1425
+ connectTimeoutMs: args.connectTimeoutMs ?? MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
1426
+ getConnection: args.getConnection,
1304
1427
  });
1305
1428
  if (remoteGit) return remoteGit;
1306
1429
  } catch {
@@ -1447,6 +1570,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
1447
1570
  workspace,
1448
1571
  timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1449
1572
  retryTimeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
1573
+ connectTimeoutMs: MESH_DIRECT_PROBE_CONNECT_TIMEOUT_MS,
1450
1574
  getConnection: args.getMeshPeerConnectionStatus,
1451
1575
  });
1452
1576
  const remoteGit = args.probeCache
@@ -1512,14 +1636,17 @@ function liveSessionRecordMatchesMeshNode(record: any, meshId: string, nodeId: s
1512
1636
  if (!recordNodeId || recordNodeId !== nodeId) return false;
1513
1637
  if (nodeIsMissingLocalWorktree) return false;
1514
1638
  const recordWorkspace = readStringValue(record?.workspace);
1515
- if (nodeWorkspace && recordWorkspace && recordWorkspace !== nodeWorkspace) return false;
1639
+ // Normalized compare (shared WTCLAIM rule): a base node and a co-located worktree
1640
+ // clone differ ONLY by workspace root, so a separator/case-skewed exact compare
1641
+ // could wrongly keep a sibling worktree's session attached to this node.
1642
+ if (nodeWorkspace && recordWorkspace && !meshWorkspacesEquivalent(recordWorkspace, nodeWorkspace)) return false;
1516
1643
  const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
1517
1644
  return !recordMeshId || recordMeshId === meshId;
1518
1645
  }
1519
1646
 
1520
1647
  function liveSessionRecordMatchesMeshWorkspace(record: any, meshId: string, workspace: string): boolean {
1521
1648
  const recordWorkspace = readStringValue(record?.workspace);
1522
- if (!recordWorkspace || !workspace || recordWorkspace !== workspace) return false;
1649
+ if (!recordWorkspace || !workspace || !meshWorkspacesEquivalent(recordWorkspace, workspace)) return false;
1523
1650
 
1524
1651
  const recordMeshId = readStringValue(record?.meta?.meshNodeFor);
1525
1652
  if (recordMeshId) return recordMeshId === meshId;
@@ -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,
@@ -1844,7 +1838,26 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1844
1838
  // re-attributed to the latest task (the normal task_completed path below).
1845
1839
  const supersedesWeakTerminal = isWeakTerminalLedgerPayload(terminal.payload)
1846
1840
  && isGenuineCompletionEvidence(args.metadataEvent);
1847
- 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) {
1848
1861
  const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1849
1862
  const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1850
1863
  const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
@@ -2467,6 +2480,24 @@ function forwardUnresolvedDelegateEvent(
2467
2480
  workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
2468
2481
  };
2469
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
+
2470
2501
  // 1) Persist durably FIRST. Idempotent on fingerprint, so a re-fired completion
2471
2502
  // does not duplicate the outbox row. If persistence fails we still attempt the
2472
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}`);