@adhdev/daemon-core 0.9.82-rc.452 → 0.9.82-rc.454

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.
@@ -324,10 +324,28 @@ export declare function updateSessionTaskStatus(meshId: string, sessionId: strin
324
324
  * Used by the completion event path to decide whether to wake the queue.
325
325
  */
326
326
  export declare function hasPendingDependents(meshId: string, taskId: string): boolean;
327
+ /**
328
+ * M1: THE single dependency-gate predicate. A task is claimable from a
329
+ * dependency standpoint iff it carries no system block (`blockedReason`) AND
330
+ * every id in `dependsOn` has reached 'completed'.
331
+ *
332
+ * DEPENDSON-GATE-SYMMETRY: every scheduler surface that decides whether a
333
+ * pending task may run MUST route through this one predicate — the queue claim
334
+ * (claimNextQueueTask), the auto-launch candidate filter
335
+ * (maybeAutoLaunchOneQueueSession), and the cloud eager P2P push
336
+ * (enqueue-and-push). If any surface computes dependency readiness on its own,
337
+ * the gate goes asymmetric and a task blocked from the pull path can still be
338
+ * eager-pushed straight to an idle session, silently bypassing its
339
+ * prerequisites. The semantics here (all deps completed && !blocked) are the
340
+ * invariant — do not fork them.
341
+ */
342
+ export declare function taskDependenciesSatisfied(entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>, statusById: Map<string, MeshTaskStatus | string>): boolean;
327
343
  /**
328
344
  * M1-4: view-time dependency state for a task — unmet dependency ids and
329
345
  * whether the task is currently claimable from a dependency standpoint.
330
- * Not stored (truth stays in task statuses).
346
+ * Not stored (truth stays in task statuses). The `dependenciesSatisfied` field
347
+ * is derived from {@link taskDependenciesSatisfied} so the view and the
348
+ * scheduler gates can never disagree.
331
349
  */
332
350
  export declare function describeTaskDependencyState(entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>, statusById: Map<string, MeshTaskStatus | string>): {
333
351
  waitingOn: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.452",
3
+ "version": "0.9.82-rc.454",
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,8 +46,8 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.452",
50
- "@adhdev/session-host-core": "0.9.82-rc.452",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.454",
50
+ "@adhdev/session-host-core": "0.9.82-rc.454",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
53
53
  "ajv-formats": "^3.0.1",
package/src/index.ts CHANGED
@@ -270,7 +270,7 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
270
270
  export type { AnyLedgerSlice, MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
271
271
 
272
272
  // ── Mesh Work Queue (GUPP) ──
273
- export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, isTaskReadonly, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState } from './mesh/mesh-work-queue.js';
273
+ export { enqueueTask, recordDirectDispatchTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, isTaskReadonly, buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, normalizeMeshCapabilityTags, resolveConvergeRequiredTags, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches, deleteDirectDispatchesByTaskId, recordMeshToolCall, assertNoDependencyCycle, hasPendingDependents, describeTaskDependencyState, taskDependenciesSatisfied } from './mesh/mesh-work-queue.js';
274
274
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord, MeshToolCallRateResult } from './mesh/mesh-work-queue.js';
275
275
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary, classifyStaleDirectForPrune, pruneStaleDirectDispatches, PRUNABLE_ORPHAN_STALE_REASONS } from './mesh/mesh-active-work.js';
276
276
  export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStaleDirectDispatchesOptions } from './mesh/mesh-active-work.js';
@@ -2,7 +2,7 @@ import type { MeshLedgerEntry } from './mesh-ledger.js';
2
2
  import { appendLedgerEntry } from './mesh-ledger.js';
3
3
  import type { MeshWorkQueueEntry, DirectDispatchRecord } from './mesh-work-queue.js';
4
4
  import { deleteDirectDispatchesByTaskId } from './mesh-work-queue.js';
5
- import { meshNodeIdMatches } from '@adhdev/mesh-shared';
5
+ import { meshNodeIdMatches, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
6
6
 
7
7
  export type MeshActiveWorkSource = 'queue' | 'direct';
8
8
  export type MeshActiveWorkStatus = 'pending' | 'assigned' | 'generating' | 'idle' | 'failed' | 'awaiting_approval';
@@ -128,9 +128,9 @@ function sessionStatusFromNodes(nodes: any[] | undefined, nodeId?: string, sessi
128
128
  if (value && typeof value === 'object') candidates.push(value);
129
129
  }
130
130
  const session = candidates.find(item => {
131
- if (typeof item === 'string') return item === sessionId;
131
+ if (typeof item === 'string') return sessionIdsEquivalent(item, sessionId);
132
132
  const id = readString(item?.id) || readString(item?.sessionId) || readString(item?.session_id) || readString(item?.runtimeSessionId) || readString(item?.instanceId);
133
- return id === sessionId;
133
+ return sessionIdsEquivalent(id, sessionId);
134
134
  });
135
135
  if (!session) return { staleReason: 'direct task session is not present in live session records' };
136
136
  if (typeof session === 'string') return {};
@@ -158,8 +158,10 @@ function terminalMatchesDispatch(terminal: MeshLedgerEntry, dispatch: MeshLedger
158
158
  const terminalTaskId = readString(terminal.payload?.taskId);
159
159
  if (terminalTaskId && terminalTaskId === taskId) return true;
160
160
  if (terminalTaskId && terminalTaskId !== taskId) return false;
161
- if (dispatch.sessionId && terminal.sessionId === dispatch.sessionId) return true;
162
- return Boolean(dispatch.nodeId && terminal.nodeId === dispatch.nodeId && !dispatch.sessionId);
161
+ if (dispatch.sessionId && sessionIdsEquivalent(terminal.sessionId, dispatch.sessionId)) return true;
162
+ // Node ids can carry interchangeable daemon-id forms (bare `mach_X` vs
163
+ // `daemon_mach_X`); compare under the canonical machine core, not raw `===`.
164
+ return Boolean(dispatch.nodeId && daemonIdsEquivalent(terminal.nodeId, dispatch.nodeId) && !dispatch.sessionId);
163
165
  }
164
166
 
165
167
  function statusFromTerminal(entry: MeshLedgerEntry): MeshActiveWorkStatus {
@@ -15,7 +15,7 @@ import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUn
15
15
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
16
16
  import { getLastDisplayMessage } from '../status/snapshot.js';
17
17
  import { resolveDelegatedWorkerAutoApprove } from '../repo-mesh-types.js';
18
- import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
19
19
  import {
20
20
  findRecentTerminalLedgerEvidence,
21
21
  hasDispatchAfterTerminal,
@@ -204,10 +204,9 @@ function hasRecentIntentionalCleanupStop(meshId: string, sessionId?: string, nod
204
204
  const timestamp = new Date(entry.timestamp).getTime();
205
205
  if (!Number.isNaN(timestamp) && timestamp < cutoff) break;
206
206
  if (!isIntentionalCleanupStopEntry(entry)) continue;
207
- // SESSION-ID IS SINGLE-FORM: a session id is one canonical UUID (crypto.randomUUID
208
- // in the provider instance), carried verbatim across daemons no serialization
209
- // variants like node/daemon ids. Exact `===` is correct; no equivalence helper.
210
- if (sessionId && entry.sessionId === sessionId) return true;
207
+ // Session ids are single-form; sessionIdsEquivalent is the one canonical
208
+ // exact-match predicate (see its doc for why no form expansion is needed).
209
+ if (sessionId && sessionIdsEquivalent(entry.sessionId, sessionId)) return true;
211
210
  // Normalized node-id match (P4): the cleanup-stop entry's node id may be stored as
212
211
  // `nodeId` or `node_id` and the `nodeId` arg can be in either form — a raw `===`
213
212
  // would miss a genuine intentional-cleanup entry and fail to suppress the stop event.
@@ -439,9 +438,9 @@ function supersedesTruncatedTerminalSummary(args: {
439
438
  // task surfaces as status='unknown' / terminalKind=null in computeMeshTaskStats.
440
439
  function resolveActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | undefined {
441
440
  try {
442
- // SESSION-ID IS SINGLE-FORM (canonical crypto.randomUUID, carried verbatim across
443
- // daemons) — exact `===` filter is correct; no node-id-style form normalization.
444
- const matches = getActiveDirectDispatches(meshId).filter(d => d.sessionId === sessionId);
441
+ // Session ids are single-form; sessionIdsEquivalent is the one canonical
442
+ // exact-match predicate no node-id-style form normalization needed.
443
+ const matches = getActiveDirectDispatches(meshId).filter(d => sessionIdsEquivalent(d.sessionId, sessionId));
445
444
  if (!matches.length) return undefined;
446
445
  // getActiveDirectDispatches returns rows ordered by dispatched_at ASC; the last is
447
446
  // the most recent dispatch (the re-dispatch / nudge whose completion this is).
@@ -1747,7 +1746,7 @@ export function flushPendingForMeshIdleCoordinators(components: DaemonComponents
1747
1746
  for (const pending of pendingEvents) {
1748
1747
  const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
1749
1748
  const targets = wantSession
1750
- ? idleCoordinators.filter(c => c.sessionId === wantSession)
1749
+ ? idleCoordinators.filter(c => sessionIdsEquivalent(c.sessionId, wantSession))
1751
1750
  : idleCoordinators;
1752
1751
  // Not deliverable into an idle target here (wrong/absent session), or a message-less
1753
1752
  // lifecycle event (agent:ready / generating_started carry no coordinatorMessage and
@@ -1826,7 +1825,7 @@ export function setupMeshEventForwarding(components: DaemonComponents) {
1826
1825
  let hasDirectDispatch = false;
1827
1826
  try {
1828
1827
  hasDirectDispatch =
1829
- getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === flushInstanceId)
1828
+ getActiveDirectDispatches(coordinatorMeshId).some(d => sessionIdsEquivalent(d.sessionId, flushInstanceId))
1830
1829
  || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, flushInstanceId);
1831
1830
  } catch { /* best-effort */ }
1832
1831
  if (!hasDirectDispatch) return;
@@ -4,7 +4,7 @@ import { updateDirectDispatchStatus, cleanupTerminalDirectDispatches } from './m
4
4
  import { markSessionDeliveriesTerminal } from './mesh-delivery-policy.js';
5
5
  import { queuePendingMeshCoordinatorEvent } from './mesh-events-pending.js';
6
6
  import { readNonEmptyString, readRecord, resolveEventSessionId, readWorkerResultMetadata, isWeakCompletionEvidence, buildMeshSystemMessage } from './mesh-events-utils.js';
7
- import { meshNodeIdMatches, type MeshNodeIdentified } from '@adhdev/mesh-shared';
7
+ import { meshNodeIdMatches, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // Stale direct-dispatch detection & transcript reconciliation
@@ -42,7 +42,7 @@ export function findRecentTerminalLedgerEvidence(args: {
42
42
  for (let i = entries.length - 1; i >= 0; i--) {
43
43
  const entry = entries[i];
44
44
  if (entry.kind !== 'task_completed' && entry.kind !== 'task_failed' && entry.kind !== 'task_stalled') continue;
45
- if (args.sessionId && entry.sessionId === args.sessionId) {
45
+ if (args.sessionId && sessionIdsEquivalent(entry.sessionId, args.sessionId)) {
46
46
  return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
47
47
  }
48
48
  // Normalized node-id match (P4): a ledger entry may store its node id as `nodeId`
@@ -70,7 +70,7 @@ export function hasDispatchAfterTerminal(meshId: string, sessionId: string, term
70
70
  if (entry.id === terminalId) pastTerminal = true;
71
71
  continue;
72
72
  }
73
- if (entry.kind === 'task_dispatched' && entry.sessionId === sessionId) return true;
73
+ if (entry.kind === 'task_dispatched' && sessionIdsEquivalent(entry.sessionId, sessionId)) return true;
74
74
  }
75
75
  return false;
76
76
  }
@@ -82,7 +82,7 @@ export function hasUnterminalDirectDispatchLedgerEntry(meshId: string, sessionId
82
82
  const entries = readLedgerEntries(meshId, { tail: 200 });
83
83
  for (let i = entries.length - 1; i >= 0; i--) {
84
84
  const entry = entries[i];
85
- if (entry.sessionId !== sessionId) continue;
85
+ if (!sessionIdsEquivalent(entry.sessionId, sessionId)) continue;
86
86
  if (entry.kind === 'task_completed' || entry.kind === 'task_failed' || entry.kind === 'task_stalled') {
87
87
  return false;
88
88
  }
@@ -109,7 +109,7 @@ export function findTerminalLedgerEvidenceForTask(args: {
109
109
  const terminalTaskId = readNonEmptyString(entry.payload?.taskId);
110
110
  if (terminalTaskId !== taskId) continue;
111
111
  if (entry.kind === 'task_completed' && isWeakCompletionEvidence(entry.payload)) continue;
112
- if (args.sessionId && entry.sessionId && entry.sessionId !== args.sessionId) continue;
112
+ if (args.sessionId && entry.sessionId && !sessionIdsEquivalent(entry.sessionId, args.sessionId)) continue;
113
113
  if (!args.sessionId && args.nodeId && entry.nodeId && !meshNodeIdMatches(entry as unknown as MeshNodeIdentified, args.nodeId)) continue;
114
114
  return { id: entry.id, kind: entry.kind, payload: entry.payload || {}, timestamp: entry.timestamp };
115
115
  }
@@ -127,7 +127,7 @@ function findDirectDispatchLedgerEntry(args: {
127
127
  if (entry.kind !== 'task_dispatched') continue;
128
128
  const payloadTaskId = readNonEmptyString(entry.payload?.taskId);
129
129
  if (payloadTaskId !== args.taskId) continue;
130
- if (args.sessionId && entry.sessionId && entry.sessionId !== args.sessionId) continue;
130
+ if (args.sessionId && entry.sessionId && !sessionIdsEquivalent(entry.sessionId, args.sessionId)) continue;
131
131
  return {
132
132
  id: entry.id,
133
133
  timestamp: entry.timestamp,
@@ -172,7 +172,7 @@ function hasTerminalLedgerAfterDispatch(args: {
172
172
  const terminalTaskId = readNonEmptyString(entry.payload?.taskId);
173
173
  if (terminalTaskId && terminalTaskId === args.taskId) return true;
174
174
  if (terminalTaskId && terminalTaskId !== args.taskId) continue;
175
- if (args.sessionId && entry.sessionId === args.sessionId) return true;
175
+ if (args.sessionId && sessionIdsEquivalent(entry.sessionId, args.sessionId)) return true;
176
176
  }
177
177
  return false;
178
178
  }
@@ -17,6 +17,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, renameSy
17
17
  import { join } from 'path';
18
18
  import { randomUUID } from 'crypto';
19
19
  import { getConfigDir } from '../config/config.js';
20
+ import { daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
20
21
  import { EventEmitter } from 'events';
21
22
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
22
23
  // ─── Types ──────────────────────────────────────
@@ -1224,7 +1225,7 @@ export function getSessionRecoveryContext(
1224
1225
  if (!failureCountDone) {
1225
1226
  if (ts < recentWindow) {
1226
1227
  failureCountDone = true;
1227
- } else if (opts.nodeId && e.nodeId !== opts.nodeId) {
1228
+ } else if (opts.nodeId && !daemonIdsEquivalent(e.nodeId, opts.nodeId)) {
1228
1229
  // Entry for a different node — skip for failure counting but continue scanning for dispatch
1229
1230
  } else if (e.kind === 'task_failed') {
1230
1231
  if (!isIntentionalCleanupStopEntry(e)) consecutiveNodeFailures++;
@@ -1236,8 +1237,8 @@ export function getSessionRecoveryContext(
1236
1237
 
1237
1238
  // Dispatch search: find the last dispatch matching this session or node
1238
1239
  if (lastDispatch === null && e.kind === 'task_dispatched') {
1239
- if (opts.sessionId && e.sessionId === opts.sessionId) { lastDispatch = e; }
1240
- else if (!opts.sessionId && opts.nodeId && e.nodeId === opts.nodeId) { lastDispatch = e; }
1240
+ if (opts.sessionId && sessionIdsEquivalent(e.sessionId, opts.sessionId)) { lastDispatch = e; }
1241
+ else if (!opts.sessionId && opts.nodeId && daemonIdsEquivalent(e.nodeId, opts.nodeId)) { lastDispatch = e; }
1241
1242
  }
1242
1243
 
1243
1244
  // Stop once both tasks are done
@@ -14,7 +14,7 @@
14
14
  import type { ProviderLoader } from '../providers/provider-loader.js';
15
15
  import { detectCLI } from '../detection/cli-detector.js';
16
16
  import { getGitRepoStatus } from '../git/git-status.js';
17
- import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent } from '@adhdev/mesh-shared';
17
+ import { normalizeGitStatus as sharedNormalizeGitStatus, pickBestTransitGitStatus as sharedPickBestTransitGitStatus, summarizeGitShape as sharedSummarizeGitShape, normalizeMeshNodeId, daemonIdsEquivalent, meshWorkspacesEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
18
18
  import { LOG } from '../logging/logger.js';
19
19
  import { getSessionHostSurfaceKind } from '../session-host/runtime-surface.js';
20
20
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from '../mesh/mesh-warmup-deadline.js';
@@ -1606,7 +1606,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
1606
1606
  const workspace = readStringValue(node?.workspace);
1607
1607
  const daemonId = readStringValue(node?.daemonId);
1608
1608
  const isSelfNode = Boolean(
1609
- nodeId && selectedCoordinatorNodeId && nodeId === selectedCoordinatorNodeId,
1609
+ nodeId && selectedCoordinatorNodeId && daemonIdsEquivalent(nodeId, selectedCoordinatorNodeId),
1610
1610
  ) || Boolean(
1611
1611
  daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
1612
1612
  ) || Boolean(args.meshSource !== 'local_config' && nodeIndex === 0);
@@ -1720,7 +1720,7 @@ export async function hydrateInlineMeshDirectTruth(args: {
1720
1720
  const nodeId = normalizeMeshNodeId(node) || `node_${nodeIndex}`;
1721
1721
  const daemonId = readStringValue(node?.daemonId);
1722
1722
  const isSelfNode = Boolean(
1723
- nodeId && selectedCoordinatorNodeId && nodeId === selectedCoordinatorNodeId,
1723
+ nodeId && selectedCoordinatorNodeId && daemonIdsEquivalent(nodeId, selectedCoordinatorNodeId),
1724
1724
  ) || Boolean(
1725
1725
  daemonId && (daemonIdsEquivalent(daemonId, args.localMachineId) || daemonIdsEquivalent(daemonId, args.statusInstanceId)),
1726
1726
  );
@@ -1794,7 +1794,10 @@ export function summarizeMeshSessionRecord(record: any): Record<string, unknown>
1794
1794
 
1795
1795
  function liveSessionRecordMatchesMeshNode(record: any, meshId: string, nodeId: string, nodeWorkspace = '', nodeIsMissingLocalWorktree = false): boolean {
1796
1796
  const recordNodeId = readStringValue(record?.meta?.meshNodeId);
1797
- if (!recordNodeId || recordNodeId !== nodeId) return false;
1797
+ // A session's stamped meshNodeId and the node's id can carry interchangeable
1798
+ // daemon-id forms (bare `mach_X` vs `daemon_mach_X`) — compare under the
1799
+ // canonical machine core, not raw `!==` (CANON-IDENTITY class).
1800
+ if (!recordNodeId || !daemonIdsEquivalent(recordNodeId, nodeId)) return false;
1798
1801
  if (nodeIsMissingLocalWorktree) return false;
1799
1802
  const recordWorkspace = readStringValue(record?.workspace);
1800
1803
  // Normalized compare (shared WTCLAIM rule): a base node and a co-located worktree
@@ -1855,7 +1858,7 @@ export function collectLiveMeshSessionRecords(args: {
1855
1858
  && !fs.existsSync(nodeWorkspace);
1856
1859
  const matches = args.liveSessionRecords.filter((record) => {
1857
1860
  const recordNodeId = readStringValue(record?.meta?.meshNodeId);
1858
- if (recordNodeId && recordNodeId !== args.nodeId) return false;
1861
+ if (recordNodeId && !daemonIdsEquivalent(recordNodeId, args.nodeId)) return false;
1859
1862
  if (liveSessionRecordMatchesMeshNode(record, args.meshId, args.nodeId, nodeWorkspace || '', nodeIsMissingLocalWorktree)) return true;
1860
1863
  if (nodeIsMissingLocalWorktree) return false;
1861
1864
  return !!nodeWorkspace && liveSessionRecordMatchesMeshWorkspace(record, args.meshId, nodeWorkspace);
@@ -1865,7 +1868,7 @@ export function collectLiveMeshSessionRecords(args: {
1865
1868
  for (const record of args.liveSessionRecords) {
1866
1869
  if (readStringValue(record?.meta?.meshCoordinatorFor) !== args.meshId) continue;
1867
1870
  const sessionId = readStringValue(record?.sessionId);
1868
- if (sessionId && matches.some((entry) => readStringValue(entry?.sessionId) === sessionId)) continue;
1871
+ if (sessionId && matches.some((entry) => sessionIdsEquivalent(readStringValue(entry?.sessionId), sessionId))) continue;
1869
1872
  matches.push(record);
1870
1873
  }
1871
1874
  }
@@ -6,7 +6,7 @@ import { getMesh } from '../config/mesh-config.js';
6
6
  import { detectCLI } from '../detection/cli-detector.js';
7
7
  import { LOG } from '../logging/logger.js';
8
8
  import { appendLedgerEntry } from './mesh-ledger.js';
9
- import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly } from './mesh-work-queue.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied } from './mesh-work-queue.js';
10
10
  import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
11
11
  import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
12
  import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
@@ -15,7 +15,7 @@ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
15
  import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
16
  import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
17
  import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
- import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
19
19
  import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
20
  import { readNonEmptyString } from './mesh-events-utils.js';
21
21
  import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent } from './mesh-events-pending.js';
@@ -834,7 +834,7 @@ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nod
834
834
  // task already running here — the CANON-IDENTITY duplicate dispatch.
835
835
  if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
836
836
  const sessionId = readNonEmptyString(state.instanceId);
837
- if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
837
+ if (currentSessionId && sessionIdsEquivalent(sessionId, currentSessionId) && isIdleSessionState(state)) return false;
838
838
  return sessionStateLooksActive(state);
839
839
  });
840
840
  }
@@ -1042,7 +1042,7 @@ function activeProviderAssignedCount(meshId: string, nodeId: string, providerTyp
1042
1042
  }
1043
1043
 
1044
1044
  export function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
1045
- if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
1045
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => sessionIdsEquivalent(task.assignedSessionId, sessionId))) {
1046
1046
  return true;
1047
1047
  }
1048
1048
  // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
@@ -1053,7 +1053,7 @@ export function sessionHasActiveAssignment(meshId: string, sessionId: string): b
1053
1053
  // session goes silently idle. This check runs before markSessionTerminal marks the
1054
1054
  // dispatch terminal, so the in-flight dispatch is still observable here.
1055
1055
  try {
1056
- if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
1056
+ if (getActiveDirectDispatches(meshId).some(d => sessionIdsEquivalent(d.sessionId, sessionId))) return true;
1057
1057
  if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
1058
1058
  } catch { /* best-effort — fall through to false */ }
1059
1059
  return false;
@@ -1287,6 +1287,10 @@ function readMeshNodeId(node: any): string {
1287
1287
 
1288
1288
  async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, meshId: string, mesh: any): Promise<boolean> {
1289
1289
  const queue = getQueue(meshId);
1290
+ // DEPENDSON-GATE-SYMMETRY: status index over the FULL queue (incl. completed)
1291
+ // so the dependency gate below sees the terminal state of every referenced
1292
+ // dependency, not just the still-active rows.
1293
+ const statusById = new Map(queue.map(task => [task.id, task.status] as const));
1290
1294
  const pending = queue.filter(task => task.status === 'pending');
1291
1295
  if (!pending.length) return false;
1292
1296
 
@@ -1300,6 +1304,16 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
1300
1304
  // higher safety cap (default 2× the write cap).
1301
1305
  const maxReadonlyParallelTasks = resolveMaxReadonlyParallelTasks(maxParallelTasks);
1302
1306
  for (const task of pending) {
1307
+ // DEPENDSON-GATE-SYMMETRY: never spawn a session for a task whose
1308
+ // dependsOn set is not all-completed (or that carries a system block). The
1309
+ // launched session would idle→claim and be refused by the SAME predicate in
1310
+ // claimNextQueueTask, producing orphan-session / re-launch churn. Skip it so
1311
+ // a later tick — after the dependency completes — launches it. Tasks with no
1312
+ // dependsOn pass through unchanged (predicate is true).
1313
+ if (!taskDependenciesSatisfied(task, statusById)) {
1314
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dependencies_unsatisfied' });
1315
+ continue;
1316
+ }
1303
1317
  const isReadonly = isTaskReadonly(task);
1304
1318
  if (isReadonly) {
1305
1319
  if (activeReadonlyAssignedCount(meshId) >= maxReadonlyParallelTasks) {
@@ -57,7 +57,7 @@ import {
57
57
  } from './mesh-unresolved-forward-outbox.js';
58
58
  import { readNonEmptyString, readMeshCompletionSummary, buildMeshSystemMessage } from './mesh-events-utils.js';
59
59
  import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
60
- import { expandDaemonIdForms, daemonIdsEquivalent } from '@adhdev/mesh-shared';
60
+ import { expandDaemonIdForms, daemonIdsEquivalent, sessionIdsEquivalent } from '@adhdev/mesh-shared';
61
61
  import { getActiveDirectDispatches, getQueue, reclaimStrandedAssignedTask, updateTaskStatus } from './mesh-work-queue.js';
62
62
  import { readLedgerEntries } from './mesh-ledger.js';
63
63
  import { pruneStaleDirectDispatches } from './mesh-active-work.js';
@@ -753,11 +753,10 @@ function drainAndInjectIntoTargets(
753
753
  // (unchanged behaviour — regression-0 for the common case).
754
754
  const wantSession = readNonEmptyString(pending.targetCoordinatorSessionId);
755
755
  if (wantSession) {
756
- // SESSION-ID IS SINGLE-FORM: a coordinator session id is one canonical
757
- // UUID (crypto.randomUUID), carried verbatim end-to-end no node/daemon-id
758
- // style serialization variants. Exact `===` is the correct match; unlike
759
- // the daemon-level set below it needs no equivalence helper.
760
- const matched = targetCoordinators.filter(c => c.sessionId === wantSession);
756
+ // Session ids are single-form; sessionIdsEquivalent is the one canonical
757
+ // exact-match predicate unlike the daemon-level set below it needs no
758
+ // form expansion.
759
+ const matched = targetCoordinators.filter(c => sessionIdsEquivalent(c.sessionId, wantSession));
761
760
  if (matched.length === 0) {
762
761
  // The originating coordinator session is not deliverable on this daemon
763
762
  // right now (gone, or modal-parked and excluded from targets). Strict mode
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { MeshLedgerEntry } from './mesh-ledger.js';
14
14
  import { buildMeshAsyncRefineJobs } from './mesh-refine-status.js';
15
+ import { daemonIdsEquivalent } from '@adhdev/mesh-shared';
15
16
 
16
17
  export type MeshReviewInboxReason = 'merge_candidate' | 'refine_blocked_review';
17
18
 
@@ -171,7 +172,7 @@ function resolveNodeEvidence(nodeId: string, ledgerEntries: MeshLedgerEntry[]):
171
172
 
172
173
  for (let i = ledgerEntries.length - 1; i >= 0; i--) {
173
174
  const entry = ledgerEntries[i];
174
- if (entry.nodeId !== nodeId || !isTerminalLedgerKind(entry.kind)) continue;
175
+ if (!daemonIdsEquivalent(entry.nodeId, nodeId) || !isTerminalLedgerKind(entry.kind)) continue;
175
176
  const payload = readRecord(entry.payload) ?? {};
176
177
 
177
178
  if (!evidence.available) {
@@ -227,7 +228,7 @@ function resolveNodeEvidence(nodeId: string, ledgerEntries: MeshLedgerEntry[]):
227
228
  function hasBlockedReviewRefineResult(nodeId: string, ledgerEntries: MeshLedgerEntry[]): boolean {
228
229
  for (let i = ledgerEntries.length - 1; i >= 0; i--) {
229
230
  const entry = ledgerEntries[i];
230
- if (entry.nodeId !== nodeId || !isTerminalLedgerKind(entry.kind)) continue;
231
+ if (!daemonIdsEquivalent(entry.nodeId, nodeId) || !isTerminalLedgerKind(entry.kind)) continue;
231
232
  const payload = readRecord(entry.payload) ?? {};
232
233
  if (payload.source !== 'refine_mesh_node_async_job') continue;
233
234
  const result = readRecord(payload.result);
@@ -275,7 +276,7 @@ export function deriveMeshReviewInboxItems(args: {
275
276
 
276
277
  const { evidence, transcriptHandle } = resolveNodeEvidence(nodeId, args.ledgerEntries);
277
278
  const activeRefineJob = [...refineJobs].reverse().find(job =>
278
- (job.nodeId === nodeId || job.targetNodeId === nodeId)
279
+ (daemonIdsEquivalent(job.nodeId, nodeId) || daemonIdsEquivalent(job.targetNodeId, nodeId))
279
280
  && (job.status === 'accepted' || job.status === 'running'),
280
281
  ) ?? null;
281
282
 
@@ -4,7 +4,7 @@ import { hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
4
4
  import { appendLedgerEntry, readLedgerEntries } from './mesh-ledger.js';
5
5
  import { LOG } from '../logging/logger.js';
6
6
  import { readNonEmptyString } from './mesh-events-utils.js';
7
- import { meshNodeIdMatches } from '@adhdev/mesh-shared';
7
+ import { meshNodeIdMatches, sessionIdsEquivalent } from '@adhdev/mesh-shared';
8
8
 
9
9
  // ---------------------------------------------------------------------------
10
10
  // R1: single-source coordinator routing resolution
@@ -117,7 +117,7 @@ export function resolveWorkerDelegateRouting(
117
117
  let hasActiveDispatch = false;
118
118
  try {
119
119
  hasActiveDispatch =
120
- getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === instanceId)
120
+ getActiveDirectDispatches(coordinatorMeshId).some(d => sessionIdsEquivalent(d.sessionId, instanceId))
121
121
  || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, instanceId);
122
122
  } catch { /* best-effort */ }
123
123
  if (!hasActiveDispatch) return reject('coordinator_not_dispatch_target');
@@ -3,8 +3,8 @@ import { dirname, join } from 'path';
3
3
  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
- import { nodeSatisfiesRequiredTags, isTaskReadonly } from './mesh-work-queue.js';
7
- import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms } from '@adhdev/mesh-shared';
6
+ import { nodeSatisfiesRequiredTags, isTaskReadonly, taskDependenciesSatisfied } from './mesh-work-queue.js';
7
+ import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent } from '@adhdev/mesh-shared';
8
8
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
9
9
  import type BetterSqlite3 from 'better-sqlite3';
10
10
  import type { Database as DatabaseHandle } from 'better-sqlite3';
@@ -756,11 +756,11 @@ export class MeshRuntimeStore {
756
756
  ).all(meshId, ...depIds) as Array<{ id: string; status: string }>;
757
757
  for (const r of depRows) depStatus.set(r.id, r.status);
758
758
  }
759
- const dependenciesSatisfied = (candidate: MeshWorkQueueEntry): boolean => {
760
- if (candidate.blockedReason) return false;
761
- const deps = Array.isArray(candidate.dependsOn) ? candidate.dependsOn : [];
762
- return deps.every(depId => depStatus.get(depId) === 'completed');
763
- };
759
+ // DEPENDSON-GATE-SYMMETRY: the claim gate shares the single
760
+ // taskDependenciesSatisfied predicate with the auto-launch filter and
761
+ // the cloud eager P2P push, so a task blocked here is blocked there too.
762
+ const dependenciesSatisfied = (candidate: MeshWorkQueueEntry): boolean =>
763
+ taskDependenciesSatisfied(candidate, depStatus);
764
764
 
765
765
  // Per-candidate node-conflict gate: write tasks require an idle node; read-only
766
766
  // tasks bypass the node-busy check so N read-only diagnoses can run on one node
@@ -795,13 +795,11 @@ export class MeshRuntimeStore {
795
795
  // an empty session. Accept the candidate when the target resolves to the
796
796
  // same node under ANY equivalent form; keep targetSessionId an exact match.
797
797
  const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
798
- // SESSION-ID IS SINGLE-FORM: unlike node/daemon ids (3 serialization
799
- // forms requiring expandDaemonIdForms), a session id is a single
800
- // canonical UUID minted once via crypto.randomUUID() in the provider
801
- // instance (cli/acp/extension/ide) and carried verbatim across daemons
802
- // (resolveEventSessionId applies no transformation). So an exact `!==`
803
- // is correct here and needs no normalization helper.
804
- if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
798
+ // Session ids are single-form (unlike node/daemon ids with their 3
799
+ // serialization forms requiring expandDaemonIdForms) see the
800
+ // sessionIdsEquivalent doc; it is the one canonical exact-match
801
+ // predicate for them.
802
+ if (candidate.targetSessionId && !sessionIdsEquivalent(candidate.targetSessionId, sessionId)) return false;
805
803
  if (
806
804
  candidate.targetNodeId
807
805
  && !daemonIdsEquivalent(candidate.targetNodeId, nodeId)
@@ -9,6 +9,7 @@ import { appendLedgerEntry } from './mesh-ledger.js';
9
9
  import type { MeshLedgerKind } from './mesh-ledger.js';
10
10
  import { createSessionDelivery } from './mesh-delivery-policy.js';
11
11
  import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
12
+ import { sessionIdsEquivalent } from '@adhdev/mesh-shared';
12
13
 
13
14
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
14
15
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
@@ -1248,7 +1249,7 @@ export function updateSessionTaskStatus(
1248
1249
  // `assigned` for 19 minutes. If the session still has an assigned row we
1249
1250
  // failed to resolve, surface it loudly instead of dropping the completion.
1250
1251
  const assignedRows = store.getActiveAssignmentDetails(meshId)
1251
- .filter(r => r.sessionId === sessionId);
1252
+ .filter(r => sessionIdsEquivalent(r.sessionId, sessionId));
1252
1253
  if (assignedRows.length > 0) {
1253
1254
  LOG.warn('MeshQueue', `No assigned queue row matched completion for mesh ${meshId} session ${sessionId} `
1254
1255
  + `(taskId=${opts?.taskId ?? 'none'}, occurredAt=${occurredAtIso ?? 'none'}); `
@@ -1275,10 +1276,36 @@ export function hasPendingDependents(meshId: string, taskId: string): boolean {
1275
1276
  .some(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(taskId));
1276
1277
  }
1277
1278
 
1279
+ /**
1280
+ * M1: THE single dependency-gate predicate. A task is claimable from a
1281
+ * dependency standpoint iff it carries no system block (`blockedReason`) AND
1282
+ * every id in `dependsOn` has reached 'completed'.
1283
+ *
1284
+ * DEPENDSON-GATE-SYMMETRY: every scheduler surface that decides whether a
1285
+ * pending task may run MUST route through this one predicate — the queue claim
1286
+ * (claimNextQueueTask), the auto-launch candidate filter
1287
+ * (maybeAutoLaunchOneQueueSession), and the cloud eager P2P push
1288
+ * (enqueue-and-push). If any surface computes dependency readiness on its own,
1289
+ * the gate goes asymmetric and a task blocked from the pull path can still be
1290
+ * eager-pushed straight to an idle session, silently bypassing its
1291
+ * prerequisites. The semantics here (all deps completed && !blocked) are the
1292
+ * invariant — do not fork them.
1293
+ */
1294
+ export function taskDependenciesSatisfied(
1295
+ entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
1296
+ statusById: Map<string, MeshTaskStatus | string>,
1297
+ ): boolean {
1298
+ if (entry.blockedReason) return false;
1299
+ const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
1300
+ return deps.every(depId => statusById.get(depId) === 'completed');
1301
+ }
1302
+
1278
1303
  /**
1279
1304
  * M1-4: view-time dependency state for a task — unmet dependency ids and
1280
1305
  * whether the task is currently claimable from a dependency standpoint.
1281
- * Not stored (truth stays in task statuses).
1306
+ * Not stored (truth stays in task statuses). The `dependenciesSatisfied` field
1307
+ * is derived from {@link taskDependenciesSatisfied} so the view and the
1308
+ * scheduler gates can never disagree.
1282
1309
  */
1283
1310
  export function describeTaskDependencyState(
1284
1311
  entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
@@ -1288,7 +1315,7 @@ export function describeTaskDependencyState(
1288
1315
  const waitingOn = deps.filter(depId => statusById.get(depId) !== 'completed');
1289
1316
  return {
1290
1317
  waitingOn,
1291
- dependenciesSatisfied: waitingOn.length === 0 && !entry.blockedReason,
1318
+ dependenciesSatisfied: taskDependenciesSatisfied(entry, statusById),
1292
1319
  };
1293
1320
  }
1294
1321