@adhdev/daemon-core 0.9.82-rc.395 → 0.9.82-rc.397

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.
@@ -34,6 +34,15 @@ export declare function reconcileDirectDispatchCompletionFromTranscript(args: {
34
34
  transcriptMessageAt?: string;
35
35
  completedAt?: string;
36
36
  targetCoordinatorDaemonId?: string;
37
+ /**
38
+ * NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
39
+ * task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
40
+ * dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
41
+ * STRICT routing matches the exact coordinator session — instead of relying on the
42
+ * daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
43
+ * ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
44
+ */
45
+ targetCoordinatorSessionId?: string;
37
46
  source?: string;
38
47
  }): {
39
48
  reconciled: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.395",
3
+ "version": "0.9.82-rc.397",
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.395",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.397",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -100,6 +100,10 @@ interface SendMessageState {
100
100
  maxEchoWaitMs: number;
101
101
  retryDelayMs: number;
102
102
  didCommitUserTurn: boolean;
103
+ // Whether this was the session's first turn at the moment of dispatch — captured
104
+ // before commitSendUserTurn flips this.firstTurnSent, so a later stuck-retry still
105
+ // knows it is recovering the win32 premature-ready first-turn swallow.
106
+ isFirstTurn: boolean;
103
107
  }
104
108
 
105
109
  interface SendMessageCompletion {
@@ -171,6 +175,16 @@ export class ProviderCliAdapter implements CliAdapter {
171
175
  private providerSessionId: string | null = null;
172
176
  private responseTimeout: NodeJS.Timeout | null = null;
173
177
  private ready = false;
178
+ // WIN32-READY-HOLD: the ready barrier can release on screen/spec-FSM grace
179
+ // before win32 ConPTY's input layer is live. The first split write (text, then a
180
+ // separate trailing CR via waitForEchoAndSubmit) then has its submit CR swallowed,
181
+ // and every CR-only retry re-sends a bare CR the input layer keeps dropping — the
182
+ // first message is typed-but-never-submitted and lost. Routing only the FIRST turn
183
+ // through the atomic content+sendKey single write (submitImmediatePrompt) keeps the
184
+ // Enter in the same PTY write unit as the text, the invariant win32 ConPTY needs to
185
+ // recognize a submit, so the swallow is bypassed. Subsequent turns (input layer now
186
+ // proven live) keep the normal echo-gated path. Flips true on first committed turn.
187
+ private firstTurnSent = false;
174
188
  private startupBuffer = '';
175
189
  private startupParseGate = false;
176
190
  private startupSettleTimer: NodeJS.Timeout | null = null;
@@ -599,6 +613,9 @@ export class ProviderCliAdapter implements CliAdapter {
599
613
  this.resetTerminalScreen(DEFAULT_SESSION_HOST_ROWS, DEFAULT_SESSION_HOST_COLS);
600
614
  this.pendingTerminalQueryTail = '';
601
615
  this.ready = false;
616
+ // Each fresh spawn re-enters the premature-ready swallow window — the next
617
+ // turn is again a "first turn" and must use the win32-safe atomic send path.
618
+ this.firstTurnSent = false;
602
619
  await this.ptyProcess.ready;
603
620
  this.engine.onSpawnReady();
604
621
  this.scheduleStartupSettleCheck();
@@ -1210,6 +1227,9 @@ export class ProviderCliAdapter implements CliAdapter {
1210
1227
  private commitSendUserTurn(state: SendMessageState): void {
1211
1228
  if (state.didCommitUserTurn) return;
1212
1229
  state.didCommitUserTurn = true;
1230
+ // The first turn has now been written atomically (win32-safe); later turns
1231
+ // can use the normal echo-gated path now that the input layer is proven live.
1232
+ this.firstTurnSent = true;
1213
1233
  }
1214
1234
 
1215
1235
  private armResponseTimeout(): void {
@@ -1240,12 +1260,31 @@ export class ProviderCliAdapter implements CliAdapter {
1240
1260
  });
1241
1261
  }
1242
1262
 
1263
+ // WIN32-READY-HOLD: choose the retry write for a stuck prompt. When the FIRST turn
1264
+ // is stuck on win32 — the premature-ready swallow window — the prompt text itself
1265
+ // may have been partially eaten by a not-yet-live ConPTY input layer, so re-sending
1266
+ // a bare CR keeps hitting nothing. Re-type the whole `text + sendKey` atomically
1267
+ // once so the input layer (now live) receives a self-contained, submit-coupled
1268
+ // write. All other cases keep the cheap bare-CR retry (the prompt is fully echoed
1269
+ // and only the Enter is missing).
1270
+ private writeStuckRetry(state: SendMessageState, mode: string): void {
1271
+ const retypeFirstTurn = process.platform === 'win32' && state.isFirstTurn;
1272
+ if (retypeFirstTurn) {
1273
+ LOG.info('CLI', `[${this.cliType}] ${mode}: re-typing full prompt atomically (win32 first-turn swallow recovery)`);
1274
+ void this.writeToPty(state.text + this.sendKey).catch((error) => {
1275
+ LOG.warn('CLI', `[${this.cliType}] ${mode} re-type write failed: ${error?.message || error}`);
1276
+ });
1277
+ return;
1278
+ }
1279
+ this.writeSubmitKeyForRetry(mode);
1280
+ }
1281
+
1243
1282
  private retrySubmitIfStuck(state: SendMessageState, attempt: number): void {
1244
1283
  this.submitRetryTimer = null;
1245
1284
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1246
1285
  this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1247
1286
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt ${attempt})`);
1248
- this.writeSubmitKeyForRetry('submit_retry');
1287
+ this.writeStuckRetry(state, 'submit_retry');
1249
1288
  if (attempt >= 3) { this.engine.submitRetryUsed = true; return; }
1250
1289
  this.submitRetryTimer = setTimeout(() => this.retrySubmitIfStuck(state, attempt + 1), state.retryDelayMs);
1251
1290
  }
@@ -1255,7 +1294,7 @@ export class ProviderCliAdapter implements CliAdapter {
1255
1294
  if (!this.isSubmitStuck(state.normalizedPromptSnippet)) return;
1256
1295
  this.engine.responseSettleIgnoreUntil = Date.now() + this.timeouts.outputSettle + 400;
1257
1296
  LOG.info('CLI', `[${this.cliType}] Retrying submit key for stuck prompt (attempt 1)`);
1258
- this.writeSubmitKeyForRetry('immediate_retry');
1297
+ this.writeStuckRetry(state, 'immediate_retry');
1259
1298
  this.engine.submitRetryUsed = true;
1260
1299
  }
1261
1300
 
@@ -1597,6 +1636,8 @@ export class ProviderCliAdapter implements CliAdapter {
1597
1636
  maxEchoWaitMs,
1598
1637
  retryDelayMs,
1599
1638
  didCommitUserTurn: false,
1639
+ // Capture BEFORE the send commits — commitSendUserTurn flips firstTurnSent.
1640
+ isFirstTurn: !this.firstTurnSent,
1600
1641
  };
1601
1642
  this.engine.responseSettleIgnoreUntil = Date.now() + submitDelayMs + this.timeouts.outputSettle + 250;
1602
1643
  await new Promise<void>((resolve, reject) => {
@@ -1615,7 +1656,15 @@ export class ProviderCliAdapter implements CliAdapter {
1615
1656
  },
1616
1657
  };
1617
1658
 
1618
- if (this.submitStrategy === 'immediate') {
1659
+ // WIN32-READY-HOLD: the very first turn after startup is the one exposed to
1660
+ // the premature-ready swallow (ready released before win32 ConPTY input is
1661
+ // live). Force it through the atomic content+sendKey single write so the
1662
+ // submit CR can never be separated from the text it submits — the same path
1663
+ // the `immediate` strategy already uses. Restricted to win32 + the first
1664
+ // turn so Mac/linux echo-gated behavior and all later turns are unchanged.
1665
+ const useAtomicFirstTurn = this.submitStrategy === 'immediate'
1666
+ || (process.platform === 'win32' && sendState.isFirstTurn);
1667
+ if (useAtomicFirstTurn) {
1619
1668
  this.submitImmediatePrompt(sendState, completion);
1620
1669
  return;
1621
1670
  }
@@ -2371,6 +2371,13 @@ export class DaemonCommandRouter {
2371
2371
  sessionCleanupMode: refineSessionCleanupMode,
2372
2372
  ...(refineSessionIds && refineSessionIds.length > 0 ? { sessionIds: refineSessionIds } : {}),
2373
2373
  inlineMesh: args?.inlineMesh,
2374
+ // REFINE-CLEANUP: refine reaches cleanup only AFTER a verified merge
2375
+ // convergence, so any residual worktree dirtiness here is incidental
2376
+ // (e.g. a bootstrap lockfile rewrite) — never unmerged work. `force`
2377
+ // sets requireClean=false so a plain-dirty worktree no longer aborts
2378
+ // removal with merged_cleanup_failed. Branch-ref deletion still keys off
2379
+ // mergeConvergence (NOT the force flag), so no merged work can be lost.
2380
+ force: true,
2374
2381
  });
2375
2382
  recordMeshRefineStage(refineStages, 'cleanup', removeResult?.success === false ? 'failed' : 'passed', cleanupStarted, {
2376
2383
  removed: removeResult?.removed,
@@ -3,7 +3,7 @@ import type { MeshLedgerKind } from './mesh-ledger.js';
3
3
  import { updateDirectDispatchStatus, cleanupTerminalDirectDispatches } from './mesh-work-queue.js';
4
4
  import { markSessionDeliveriesTerminal } from './mesh-delivery-policy.js';
5
5
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
6
- import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence } from './mesh-events-utils.js';
6
+ import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence, buildMeshSystemMessage } from './mesh-events-utils.js';
7
7
  import { meshNodeIdMatches, type MeshNodeIdentified } from '@adhdev/mesh-shared';
8
8
 
9
9
  // ---------------------------------------------------------------------------
@@ -188,6 +188,15 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
188
188
  transcriptMessageAt?: string;
189
189
  completedAt?: string;
190
190
  targetCoordinatorDaemonId?: string;
191
+ /**
192
+ * NOTIF-DROP-SYNTH-NO-MESSAGE: the originating coordinator SESSION that dispatched this
193
+ * task (the `coordinatorSessionId` stamped onto the task_dispatched ledger payload at
194
+ * dispatch). Stamped onto the synthesized completion's targetCoordinatorSessionId so PHASE 2
195
+ * STRICT routing matches the exact coordinator session — instead of relying on the
196
+ * daemon-keyed fallback. When the caller does not pass it, it is recovered from the dispatch
197
+ * ledger entry below; absent on legacy rows → daemon-level routing (unchanged).
198
+ */
199
+ targetCoordinatorSessionId?: string;
191
200
  source?: string;
192
201
  }): { reconciled: boolean; kind?: MeshLedgerKind; alreadyTerminal?: boolean; workerResult?: unknown; ledgerEntryId?: string; reason?: string } {
193
202
  const finalSummary = readNonEmptyString(args.finalSummary);
@@ -282,27 +291,44 @@ export function reconcileDirectDispatchCompletionFromTranscript(args: {
282
291
  updateDirectDispatchStatus(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed', args.taskId);
283
292
  markSessionDeliveriesTerminal(args.meshId, args.sessionId, kind === 'task_completed' ? 'completed' : 'failed');
284
293
  setImmediate(() => cleanupTerminalDirectDispatches());
294
+ // NOTIF-DROP-SYNTH-NO-MESSAGE: the queued synth completion MUST carry a coordinatorMessage,
295
+ // or injectPendingIntoCoordinator early-returns (`!pending.coordinatorMessage`) and the row is
296
+ // drained-without-inject → no [System] surface. The ~8s-later native completion then collides
297
+ // on the taskId-anchored fingerprint and is blocked at INSERT, so the notification is lost
298
+ // forever. Build the SAME [System] message the native path builds (buildMeshSystemMessage) so
299
+ // the synth is itself a complete, deliverable completion. Routing is made STRICT too: stamp the
300
+ // originating coordinator session so PHASE 2 strict-match delivers to the exact coordinator
301
+ // session that dispatched the task instead of falling back to a daemon-level broadcast.
302
+ const eventName = kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped';
303
+ const nodeLabel = nodeId ? `Node '${nodeId}'` : 'Remote agent';
304
+ const metadataEvent = {
305
+ targetSessionId: args.sessionId,
306
+ providerType: providerType || undefined,
307
+ providerSessionId: readNonEmptyString(args.providerSessionId),
308
+ finalSummary,
309
+ taskId: args.taskId,
310
+ workerResult,
311
+ completionDiagnostic: {
312
+ reason: 'direct_task_transcript_reconciliation',
313
+ terminalLedgerKind: kind,
314
+ terminalLedgerId: entry.id,
315
+ },
316
+ };
317
+ // Originating coordinator session: prefer the explicit arg; otherwise recover it from the
318
+ // task_dispatched ledger payload (the MCP dispatch path stamps `coordinatorSessionId` there).
319
+ // Absent on legacy rows → undefined → daemon-level routing (unchanged, no regression).
320
+ const targetCoordinatorSessionId = readNonEmptyString(args.targetCoordinatorSessionId)
321
+ || readNonEmptyString(dispatch?.payload?.coordinatorSessionId);
285
322
  queuePendingMeshCoordinatorEvent({
286
- event: kind === 'task_completed' ? 'agent:generating_completed' : 'agent:stopped',
323
+ event: eventName,
287
324
  meshId: args.meshId,
288
- nodeLabel: nodeId ? `Node '${nodeId}'` : 'Remote agent',
325
+ nodeLabel,
289
326
  nodeId: nodeId || undefined,
290
- metadataEvent: {
291
- targetSessionId: args.sessionId,
292
- providerType: providerType || undefined,
293
- providerSessionId: readNonEmptyString(args.providerSessionId),
294
- finalSummary,
295
- taskId: args.taskId,
296
- workerResult,
297
- completionDiagnostic: {
298
- reason: 'direct_task_transcript_reconciliation',
299
- terminalLedgerKind: kind,
300
- terminalLedgerId: entry.id,
301
- },
302
- },
303
- coordinatorMessage: undefined,
327
+ metadataEvent,
328
+ coordinatorMessage: buildMeshSystemMessage({ event: eventName, nodeLabel, metadataEvent }),
304
329
  queuedAt: Date.now(),
305
330
  ...(readNonEmptyString(args.targetCoordinatorDaemonId) ? { targetCoordinatorDaemonId: readNonEmptyString(args.targetCoordinatorDaemonId) } : {}),
331
+ ...(targetCoordinatorSessionId ? { targetCoordinatorSessionId } : {}),
306
332
  });
307
333
 
308
334
  return { reconciled: true, kind, workerResult, ledgerEntryId: entry.id };
@@ -267,7 +267,10 @@ export function tryAssignQueueTask(
267
267
  providerType: string
268
268
  ): boolean {
269
269
  const mesh = getMeshWithCache(components, meshId);
270
- const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
270
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
271
+ // `n.id` — a stamp-form nodeId vs the mesh node's config-form id must still
272
+ // resolve, mirroring the remote idle-session path below (:1341).
273
+ const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, nodeId));
271
274
 
272
275
  // WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
273
276
  // tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
@@ -1317,7 +1320,7 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1317
1320
 
1318
1321
  if (providerType) {
1319
1322
  localIdleSessionsChecked += 1;
1320
- localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => readMeshNodeId(n) === nodeId) });
1323
+ localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId)) });
1321
1324
  } else {
1322
1325
  skippedSessions.push({
1323
1326
  nodeId,
@@ -55,7 +55,7 @@ import {
55
55
  ackUnresolvedDelegateForward,
56
56
  expireStaleUnresolvedDelegateForwards,
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
- import { readNonEmptyString, readMeshCompletionSummary } from './mesh-events-utils.js';
58
+ import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
59
59
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
60
60
  import { expandDaemonIdForms, daemonIdsEquivalent } from '@adhdev/mesh-shared';
61
61
  import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
@@ -368,7 +368,30 @@ function injectPendingIntoCoordinator(
368
368
  coordinator: LiveCoordinator['instance'],
369
369
  pending: PendingMeshCoordinatorEvent,
370
370
  ): void {
371
- if (!coordinator || !pending.coordinatorMessage) return;
371
+ if (!coordinator) return;
372
+ // NOTIF-DROP-SYNTH-NO-MESSAGE (defence-in-depth): a queued event with no coordinatorMessage
373
+ // used to be dropped here (drain-without-inject) — the row had already been consumed
374
+ // (drained=1) by the caller's drain, so silently returning lost it forever. The primary fix
375
+ // makes the transcript-reconcile synth always carry a coordinatorMessage, but as a backstop,
376
+ // lazily synthesize the [System] text for any force-inject (terminal: completion / approval /
377
+ // stop / refine·bootstrap) event that still arrives message-less, so it surfaces instead of
378
+ // vanishing. A NON-force lifecycle event (agent:ready / generating_started) legitimately
379
+ // carries no message and must NOT be injected (it is queued only to re-drive the claim state
380
+ // machine on pull) — for it we still return without injecting.
381
+ let coordinatorMessage = pending.coordinatorMessage;
382
+ if (!coordinatorMessage) {
383
+ if (!shouldForceInjectMeshEvent(pending.event)) return;
384
+ const metadataEvent = pending.metadataEvent && typeof pending.metadataEvent === 'object'
385
+ ? pending.metadataEvent
386
+ : {};
387
+ coordinatorMessage = buildMeshSystemMessage({
388
+ event: pending.event,
389
+ nodeLabel: pending.nodeLabel,
390
+ metadataEvent,
391
+ });
392
+ if (!coordinatorMessage) return; // builder produced nothing — nothing to surface
393
+ LOG.warn('MeshReconcile', `Lazily synthesized missing coordinatorMessage for ${pending.event} (mesh ${pending.meshId}) at inject time — a queued terminal event arrived message-less`);
394
+ }
372
395
  const force = shouldForceInjectMeshEvent(pending.event);
373
396
  // EVTTRACE: event surfaced to the coordinator (injected into its live CLI session).
374
397
  // This is the terminal happy-path stage. Observation only.
@@ -380,7 +403,7 @@ function injectPendingIntoCoordinator(
380
403
  event: pending.event,
381
404
  }, force ? 'force-inject' : 'inject');
382
405
  coordinator.onEvent('send_message', {
383
- input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
406
+ input: { text: coordinatorMessage, textFallback: coordinatorMessage },
384
407
  ...(force ? { force: true } : {}),
385
408
  });
386
409
  }
@@ -4,6 +4,7 @@ import { LOG } from '../logging/logger.js';
4
4
  import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
5
5
  import { getLedgerDir } from './mesh-ledger.js';
6
6
  import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
7
+ import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
7
8
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
8
9
  import type BetterSqlite3 from 'better-sqlite3';
9
10
  import type { Database as DatabaseHandle } from 'better-sqlite3';
@@ -687,6 +688,15 @@ export class MeshRuntimeStore {
687
688
  return null;
688
689
  }
689
690
 
691
+ // The node-pinned SELECT must match a row whose target_node_id was stamped
692
+ // in ANY equivalent daemon-id form (config-form `daemon_mach_X` vs the
693
+ // claiming session's stamp-form `mach_X`). A single `= ?` bind on the
694
+ // stamp-form silently fails to fetch a config-form row, leaving the task
695
+ // pending forever (the empty-session WORKTREE-CLAIM-GATE repro). Expand to
696
+ // every equivalent form and bind an IN (...) set; the per-candidate
697
+ // targetMatches() JS gate above re-validates each fetched row.
698
+ const nodeIdForms = expandDaemonIdForms(nodeId);
699
+ const nodePinnedPlaceholders = nodeIdForms.map(() => '?').join(', ');
690
700
  // Priority: session-targeted > node-targeted (no session) > unconstrained
691
701
  const rows = [
692
702
  ...(
@@ -699,9 +709,9 @@ export class MeshRuntimeStore {
699
709
  ...(
700
710
  this.db.prepare(`
701
711
  SELECT payload FROM mesh_queue
702
- WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
712
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IN (${nodePinnedPlaceholders}) AND target_session_id IS NULL
703
713
  ORDER BY created_at ASC
704
- `).all(meshId, nodeId) as Array<{ payload: string }>
714
+ `).all(meshId, ...nodeIdForms) as Array<{ payload: string }>
705
715
  ),
706
716
  ...(
707
717
  this.db.prepare(`
@@ -755,9 +765,21 @@ export class MeshRuntimeStore {
755
765
  // sibling worktree session on the same daemon absorb another node's/session's
756
766
  // pinned task. When a task carries an explicit target, require an exact match
757
767
  // here too — fail-closed.
768
+ // The target id may have been stamped in a different serialization /
769
+ // daemon-id form than the claiming session's nodeId (config-form
770
+ // `daemon_mach_X` vs stamp-form `mach_X`, or the 3-way id/nodeId/node_id
771
+ // node forms). A raw `!==` here permanently strands a node-pinned task as
772
+ // an empty session. Accept the candidate when the target resolves to the
773
+ // same node under ANY equivalent form; keep targetSessionId an exact match.
758
774
  const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
759
775
  if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
760
- if (candidate.targetNodeId && candidate.targetNodeId !== nodeId) return false;
776
+ if (
777
+ candidate.targetNodeId
778
+ && !daemonIdsEquivalent(candidate.targetNodeId, nodeId)
779
+ && !meshNodeIdMatches({ id: candidate.targetNodeId }, nodeId)
780
+ ) {
781
+ return false;
782
+ }
761
783
  return true;
762
784
  };
763
785