@adhdev/daemon-core 0.9.82-rc.316 → 0.9.82-rc.318

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.
@@ -41,6 +41,12 @@ export declare class MeshRuntimeStore {
41
41
  * untargeted work spreads instead of piling onto whichever node asks first.
42
42
  */
43
43
  nodeActiveAssignmentCount(meshId: string, nodeId: string): number;
44
+ /**
45
+ * O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
46
+ * indexed status column, so it avoids JSON.parse-ing every queue row — used as a
47
+ * cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
48
+ */
49
+ pendingQueueTaskCount(meshId: string): number;
44
50
  /**
45
51
  * Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
46
52
  * the tie-break winner among nodes tied at the least load.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.316",
3
+ "version": "0.9.82-rc.318",
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.316",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.318",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -6,6 +6,42 @@ import * as path from 'path';
6
6
  // need a cmd.exe wrapper).
7
7
  const DIRECT_EXEC_EXT = new Set(['.exe', '.com']);
8
8
 
9
+ // Executable extensions to probe when scanning a directory ourselves, ordered
10
+ // most-directly-launchable first. node-pty's ConPTY backend launches an
11
+ // absolute `.cmd`/`.bat` shim fine (verified) — it only fails to *resolve* a
12
+ // bare command against an incomplete PATH — so once we hand it an absolute
13
+ // path, a `.cmd` shim works just as well as a real `.exe`.
14
+ const WIN_EXEC_EXT = ['.exe', '.com', '.cmd', '.bat'];
15
+
16
+ /**
17
+ * Resolve a bare command against well-known global-bin directories that are
18
+ * frequently NOT on the daemon's inherited PATH, so `where` (which only
19
+ * searches PATH) misses them. A daemon running under one Node install (e.g.
20
+ * nvm) never sees another npm prefix's bin dir — notably npm's Windows default
21
+ * prefix at %APPDATA%\npm, where `npm i -g @openai/codex` lands. Returns an
22
+ * absolute path on the first hit, or null. Mirrors findBinary()'s extraDirs in
23
+ * provider-cli-shared.ts; kept inline here so this lightweight module (loaded
24
+ * by pty-transport) need not pull in the heavier shared module.
25
+ */
26
+ function resolveWin32GlobalBin(trimmed: string): string | null {
27
+ // Only resolve a bare command name — anything with a path separator is the
28
+ // caller's explicit location and must not be re-pointed at a global bin dir.
29
+ if (path.isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
30
+ return null;
31
+ }
32
+ const extraDirs: string[] = [];
33
+ if (process.env.APPDATA) extraDirs.push(path.join(process.env.APPDATA, 'npm'));
34
+ try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
35
+ for (const dir of extraDirs) {
36
+ if (!dir) continue;
37
+ for (const ext of WIN_EXEC_EXT) {
38
+ const full = path.join(dir, trimmed + ext);
39
+ if (existsSync(full)) return full;
40
+ }
41
+ }
42
+ return null;
43
+ }
44
+
9
45
  /**
10
46
  * Resolve a launch command to an absolute executable path on Windows.
11
47
  *
@@ -39,7 +75,16 @@ export function resolveWin32Executable(command: string): string {
39
75
  return direct || matches[0] || command;
40
76
  }
41
77
  } catch {
42
- // `where` not found / non-zero exit — fall through to original command.
78
+ // `where` not found / non-zero exit — fall through to the global-bin scan.
43
79
  }
80
+
81
+ // `where` found nothing on PATH. Before giving up (and letting node-pty crash
82
+ // with "File not found:" on the bare command), search npm's off-PATH global
83
+ // bin dir(s). This is the codex case: `npm i -g @openai/codex` installs to
84
+ // %APPDATA%\npm, which is absent from a nvm-launched daemon's PATH, so a spec
85
+ // binary of "codex" never resolved and the spawn ENOENT'd.
86
+ const globalBin = resolveWin32GlobalBin(trimmed);
87
+ if (globalBin) return globalBin;
88
+
44
89
  return command;
45
90
  }
@@ -1094,6 +1094,25 @@ function readMeshConnectionState(connection: Record<string, unknown> | null | un
1094
1094
  return readStringValue((connection as any)?.state);
1095
1095
  }
1096
1096
 
1097
+ /**
1098
+ * Connection states that mean the peer is definitively NOT reachable right now —
1099
+ * an offline machine (no peer entry at all) or a transport that has dropped
1100
+ * (failed/closed/disconnected). Probing such a peer would just burn the full
1101
+ * MESH_DIRECT_PROBE_TIMEOUT_MS window before timing out, so the cold-open of the
1102
+ * mesh graph stalls 25s behind one powered-off node. `connecting` is deliberately
1103
+ * NOT here: a peer mid-handshake may complete during the probe window, so it still
1104
+ * gets its attempt. Held standing git truth is consulted by the caller BEFORE this
1105
+ * runs, so the invariant "connected+held is never unavailable" is untouched — this
1106
+ * only short-circuits a peer that has no usable transport to probe over.
1107
+ */
1108
+ function isMeshConnectionDefinitivelyDown(
1109
+ connection: Record<string, unknown> | null | undefined,
1110
+ ): boolean {
1111
+ if (!connection) return true;
1112
+ const state = readMeshConnectionState(connection);
1113
+ return state === 'failed' || state === 'closed' || state === 'disconnected';
1114
+ }
1115
+
1097
1116
  /**
1098
1117
  * Probe a remote peer's git_status with a bounded retry budget, but only while
1099
1118
  * the peer is reported `connected`. A single slow (often TURN-relayed) peer can
@@ -1117,6 +1136,19 @@ async function probeRemoteMeshGitStatusWithRetry(args: {
1117
1136
  getConnection?: (daemonId: string) => Record<string, unknown> | null;
1118
1137
  onConnection?: (connection: Record<string, unknown>) => void;
1119
1138
  }): Promise<Record<string, unknown> | null> {
1139
+ // Fast-fail an offline / dropped peer BEFORE the first attempt. Previously the
1140
+ // liveness re-check only ran *between* attempts, so a powered-off node still ate
1141
+ // the full first MESH_DIRECT_PROBE_TIMEOUT_MS (25s) window — stalling the mesh
1142
+ // graph cold-open behind one dead machine. If a connection getter is wired and
1143
+ // it reports the peer as definitively down (no peer entry / failed / closed /
1144
+ // disconnected), skip straight to "no truth" instead of awaiting a 25s timeout.
1145
+ // A `connecting` peer still gets its attempt (it may complete mid-probe). No
1146
+ // onConnection side effect here: this is a pure liveness gate, and the caller's
1147
+ // own connection read already seeds status.connection — only the between-attempt
1148
+ // path needs to surface a freshly-observed connection.
1149
+ if (args.getConnection && isMeshConnectionDefinitivelyDown(args.getConnection(args.daemonId))) {
1150
+ return null;
1151
+ }
1120
1152
  for (let attempt = 0; attempt <= MESH_DIRECT_PROBE_MAX_RETRIES; attempt += 1) {
1121
1153
  if (attempt > 0) {
1122
1154
  // Re-check liveness before spending another probe window; a peer that
@@ -12,6 +12,7 @@ import * as os from 'os';
12
12
  import * as path from 'path';
13
13
  import { existsSync } from 'fs';
14
14
  import type { ProviderLoader } from '../providers/provider-loader.js';
15
+ import { findBinary } from '../cli-adapters/provider-cli-shared.js';
15
16
 
16
17
  export interface CLIInfo {
17
18
  id: string;
@@ -57,6 +58,29 @@ function resolveCommandPath(command: string): string | null {
57
58
  return null;
58
59
  }
59
60
 
61
+ /**
62
+ * Resolve a CLI command to an absolute path for install detection.
63
+ * Order: explicit path → PATH (`where`/`which`) → well-known global-bin dirs
64
+ * (e.g. %APPDATA%\npm) via the shared spawn-layer findBinary.
65
+ *
66
+ * The final step keeps detection consistent with the spawn layer: findBinary
67
+ * already searches npm's default Windows prefix at %APPDATA%\npm (where
68
+ * `npm i -g @openai/codex` lands), so a CLI installed under an npm prefix that
69
+ * is NOT on the daemon's inherited PATH is detected as installed instead of
70
+ * being blocked at the launch gate. findBinary returns a bare "<name>.cmd"
71
+ * (non-absolute) when nothing is found, so we only accept an absolute path
72
+ * that actually exists.
73
+ */
74
+ async function resolveDetectionPath(command: string, whichCmd: string): Promise<string | null> {
75
+ const explicitPath = resolveCommandPath(command);
76
+ if (explicitPath) return explicitPath;
77
+ const whichResult = await execAsync(`${whichCmd} ${shellQuote(command)}`);
78
+ if (whichResult) return whichResult.split('\n')[0];
79
+ const resolved = findBinary(command);
80
+ if (path.isAbsolute(resolved) && existsSync(resolved)) return resolved;
81
+ return null;
82
+ }
83
+
60
84
  /** Run a shell command with timeout, returning stdout or null on failure */
61
85
  function execAsync(cmd: string, timeoutMs = 5000): Promise<string | null> {
62
86
  return new Promise((resolve) => {
@@ -97,11 +121,8 @@ export async function detectCLIs(
97
121
  const results = await Promise.all(
98
122
  cliList.map(async (cli): Promise<CLIInfo> => {
99
123
  try {
100
- const explicitPath = resolveCommandPath(cli.command);
101
- const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(cli.command)}`);
102
- if (!pathResult) return { ...cli, installed: false };
103
-
104
- const firstPath = explicitPath || pathResult.split('\n')[0];
124
+ const firstPath = await resolveDetectionPath(cli.command, whichCmd);
125
+ if (!firstPath) return { ...cli, installed: false };
105
126
 
106
127
  // Get version (parallel with other checks)
107
128
  let version: string | undefined;
@@ -148,10 +169,8 @@ export async function detectCLI(
148
169
  const platform = os.platform();
149
170
  const whichCmd = platform === 'win32' ? 'where' : 'which';
150
171
  try {
151
- const explicitPath = resolveCommandPath(target.command);
152
- const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(target.command)}`);
153
- if (!pathResult) return null;
154
- const firstPath = explicitPath || pathResult.split('\n')[0];
172
+ const firstPath = await resolveDetectionPath(target.command, whichCmd);
173
+ if (!firstPath) return null;
155
174
  let version: string | undefined;
156
175
  if (options?.includeVersion !== false) {
157
176
  const versionCommands = [
@@ -560,13 +560,24 @@ async function getSubmoduleStatuses(
560
560
  if (!repo.repoRoot) return [];
561
561
 
562
562
  try {
563
- // No `--recursive`: this superproject's submodules (oss, adhdev-providers) are
564
- // leaf repos with no nested submodules, so `--recursive` doubles the (already
565
- // slow on Windows) submodule-status spawn time for zero additional rows. If a
566
- // nested submodule is ever introduced, restore --recursive WITH its own longer
567
- // per-command timeout rather than reverting this wholesale.
568
- const result = await runGit(repo, ['submodule', 'status'], options);
569
- const submodules = parseSubmoduleStatusOutput(result.stdout, repo.repoRoot, options.submoduleIgnorePaths);
563
+ // Do NOT shell out to `git submodule status`. That porcelain wrapper is a
564
+ // shell script (`git-submodule`) that, per submodule, spawns several child
565
+ // `git` processes; on Windows the wrapper + per-spawn cost alone measured
566
+ // 6.9–62.4s under AV, which dominated the whole collectGitRepoStatus budget
567
+ // and stalled the mesh graph cold-open. The information it gives us — the
568
+ // gitlink sync state (path / recorded SHA / +/-/U prefix) — is fully
569
+ // derivable from plumbing commands that don't go through the shell wrapper:
570
+ // • paths ← `.gitmodules` (git config --file, plumbing)
571
+ // • expected SHA ← `git ls-tree HEAD <path>` (the gitlink the super-
572
+ // project's HEAD tree records)
573
+ // • actual SHA ← `git -C <sub> rev-parse HEAD` (already paid below by
574
+ // enrichSubmoduleWorktreeStatus for the dirty check)
575
+ // Comparing expected vs actual reproduces `+` (out of sync); a checked-out
576
+ // submodule whose worktree is absent/uninitialized reproduces `-`. The `U`
577
+ // (conflict) prefix is surfaced separately via the superproject porcelain
578
+ // status that the caller already parses, and a conflicted submodule's own
579
+ // status read here also flags it dirty — so no row is lost.
580
+ const submodules = await deriveSubmoduleGitlinkStatuses(repo, options);
570
581
  await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
571
582
  return submodules;
572
583
  } catch {
@@ -574,6 +585,110 @@ async function getSubmoduleStatuses(
574
585
  }
575
586
  }
576
587
 
588
+ /**
589
+ * Enumerate the superproject's submodules and their gitlink sync state without the
590
+ * slow `git submodule status` shell wrapper. Pure plumbing: read paths from
591
+ * `.gitmodules`, the expected (recorded) gitlink SHA from `ls-tree HEAD`, and the
592
+ * actual checked-out SHA from the submodule's own `rev-parse HEAD`.
593
+ */
594
+ async function deriveSubmoduleGitlinkStatuses(
595
+ repo: ResolvedGitRepo,
596
+ options: GitStatusOptions,
597
+ ): Promise<GitSubmoduleStatus[]> {
598
+ if (!repo.repoRoot) return [];
599
+ const paths = await readSubmodulePaths(repo, options);
600
+ const ignoreSet = new Set(options.submoduleIgnorePaths || []);
601
+ const lastCheckedAt = Date.now();
602
+
603
+ const entries = await Promise.all(
604
+ paths
605
+ .filter(path => !ignoreSet.has(path))
606
+ .map(async (path): Promise<GitSubmoduleStatus> => {
607
+ const repoPath = repo.repoRoot + '/' + path;
608
+ const expected = await readGitlinkExpectedSha(repo, path, options);
609
+ const actual = await readSubmoduleHeadSha(repo, repoPath, options);
610
+ // Uninitialized / no checked-out HEAD reproduces `git submodule status`'s
611
+ // `-` prefix; a present-but-divergent HEAD reproduces the `+` prefix.
612
+ const outOfSync = actual === null
613
+ ? true
614
+ : expected !== null && expected !== actual;
615
+ return {
616
+ path,
617
+ // Prefer the recorded gitlink SHA (matches the legacy column); fall back
618
+ // to the checked-out SHA so the field is never empty when both are known.
619
+ commit: expected ?? actual ?? '',
620
+ repoPath,
621
+ dirty: false,
622
+ outOfSync,
623
+ lastCheckedAt,
624
+ };
625
+ }),
626
+ );
627
+ return entries;
628
+ }
629
+
630
+ /** Read submodule paths from `.gitmodules` via plumbing (no shell wrapper). */
631
+ async function readSubmodulePaths(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<string[]> {
632
+ if (!repo.repoRoot) return [];
633
+ const gitmodulesPath = repo.repoRoot + '/.gitmodules';
634
+ try {
635
+ const result = await runGit(
636
+ repo,
637
+ ['config', '--file', gitmodulesPath, '--get-regexp', '^submodule\\..*\\.path$'],
638
+ options,
639
+ );
640
+ const paths: string[] = [];
641
+ for (const line of result.stdout.split('\n')) {
642
+ // Each line: `submodule.<name>.path <path>`
643
+ const spaceIdx = line.indexOf(' ');
644
+ if (spaceIdx < 0) continue;
645
+ const value = line.slice(spaceIdx + 1).trim();
646
+ if (value) paths.push(value);
647
+ }
648
+ return paths;
649
+ } catch {
650
+ // No .gitmodules (not a superproject) or unreadable → no submodules.
651
+ return [];
652
+ }
653
+ }
654
+
655
+ /** Expected gitlink SHA recorded in the superproject HEAD tree for this submodule path. */
656
+ async function readGitlinkExpectedSha(
657
+ repo: ResolvedGitRepo,
658
+ submodulePath: string,
659
+ options: GitStatusOptions,
660
+ ): Promise<string | null> {
661
+ try {
662
+ // `ls-tree HEAD <path>` prints: `<mode> commit <sha>\t<path>` for a gitlink.
663
+ const result = await runGit(repo, ['ls-tree', 'HEAD', submodulePath], options);
664
+ const line = result.stdout.split('\n').find(l => l.trim().length > 0);
665
+ if (!line) return null;
666
+ const match = line.match(/^\s*\d+\s+commit\s+([0-9a-f]{40})\b/);
667
+ return match ? match[1] : null;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
672
+
673
+ /** Actual checked-out HEAD SHA of a submodule, or null if uninitialized/unreadable. */
674
+ async function readSubmoduleHeadSha(
675
+ repo: ResolvedGitRepo,
676
+ repoPath: string,
677
+ options: GitStatusOptions,
678
+ ): Promise<string | null> {
679
+ try {
680
+ // Run in the submodule worktree via cwd (inside the superproject root, so the
681
+ // executor's path-inside-repo guard is satisfied) rather than resolving the
682
+ // submodule as a fresh repo — that would cost an extra `rev-parse --show-toplevel`
683
+ // spawn per submodule, which is exactly the Windows spawn cost this fix removes.
684
+ const result = await runGit(repo, ['rev-parse', 'HEAD'], { ...options, cwd: repoPath });
685
+ const sha = result.stdout.trim();
686
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
687
+ } catch {
688
+ return null;
689
+ }
690
+ }
691
+
577
692
  async function enrichSubmoduleWorktreeStatus(
578
693
  repo: ResolvedGitRepo,
579
694
  submodule: GitSubmoduleStatus,
@@ -594,37 +709,3 @@ async function enrichSubmoduleWorktreeStatus(
594
709
  }
595
710
  }
596
711
 
597
- function parseSubmoduleStatusOutput(
598
- output: string,
599
- repoRoot: string,
600
- ignorePaths?: string[],
601
- ): GitSubmoduleStatus[] {
602
- const submodules: GitSubmoduleStatus[] = [];
603
- const ignoreSet = new Set(ignorePaths || []);
604
-
605
- for (const line of output.split('\n')) {
606
- if (!line.trim()) continue;
607
-
608
- // Format: [+-U ]<commit> <path> (<branch>)
609
- // - = not initialized, + = gitlink out of sync, U = conflict, ' ' = aligned.
610
- const match = line.match(/^([\-+U\s])([0-9a-f]{40})\s+(\S+)(?:\s+\(([^)]+)\))?/);
611
- if (!match) continue;
612
-
613
- const prefix = match[1];
614
- const commit = match[2];
615
- const path = match[3];
616
-
617
- if (ignoreSet.has(path)) continue;
618
-
619
- submodules.push({
620
- path,
621
- commit,
622
- repoPath: repoRoot + '/' + path,
623
- dirty: prefix === 'U',
624
- outOfSync: prefix === '-' || prefix === '+',
625
- lastCheckedAt: Date.now(),
626
- });
627
- }
628
-
629
- return submodules;
630
- }
@@ -1027,7 +1027,11 @@ export async function triggerMeshQueue(components: DaemonComponents, meshId: str
1027
1027
 
1028
1028
  const remoteCandidates: IdleCandidate[] = [];
1029
1029
  for (const idle of remoteSessions) {
1030
- const node = mesh.nodes.find((n: any) => n.id === idle.nodeId);
1030
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
1031
+ // `n.id`, so an inline-cached worktree node whose identity arrived under a
1032
+ // different form is not silently dropped — leaving a remote idle session
1033
+ // unable to claim its pending queue task.
1034
+ const node = mesh.nodes.find((n: any) => meshNodeIdMatches(n, idle.nodeId));
1031
1035
  if (node) {
1032
1036
  remoteIdleSessionsChecked += 1;
1033
1037
  remoteCandidates.push({ nodeId: idle.nodeId, sessionId: idle.sessionId, providerType: idle.providerType, origin: 'remote', node });
@@ -48,7 +48,7 @@ import { LOG } from '../logging/logger.js';
48
48
  import { drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
49
49
  import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
50
50
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
51
- import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS } from './mesh-events-coordinator.js';
51
+ import { handleMeshForwardEvent, shouldForceInjectMeshEvent, MESH_FORCE_INJECT_EVENTS, triggerMeshQueue } from './mesh-events-coordinator.js';
52
52
  import {
53
53
  peekUnresolvedDelegateForwards,
54
54
  ackUnresolvedDelegateForward,
@@ -251,6 +251,36 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
251
251
  }
252
252
  }
253
253
 
254
+ // ── PHASE 3: recover pending queue claims for newly-idle sessions ──────────
255
+ // The event-driven claim paths (agent:ready / agent:generating_completed in
256
+ // mesh-events-coordinator) re-claim the queue the moment a session goes idle,
257
+ // but that depends on a single event being emitted AND (for a remote node)
258
+ // successfully forwarded to this coordinator. If that event is missed/dropped,
259
+ // a pending task targeting a now-idle session would sit unclaimed forever —
260
+ // there was no periodic safety net. This phase is that net: for every mesh this
261
+ // daemon hosts that has at least one pending task, run one triggerMeshQueue so a
262
+ // session that became idle without a delivered ready-event still gets its work.
263
+ //
264
+ // O(1) guard: skip the (relatively expensive) full idle-session + remote-idle
265
+ // scan entirely when the queue has no pending tasks — a COUNT(*) over the
266
+ // indexed status column, so an idle mesh costs one cheap query per tick.
267
+ // claimNextQueueTask is atomic, so racing the event-driven path can only have
268
+ // one winner; double-claiming is impossible.
269
+ for (const mesh of listMeshes()) {
270
+ const selfIds = resolveCoordinatorSelfIds(mesh, drainDaemonIds);
271
+ if (!daemonHostsMesh(mesh, selfIds)) continue;
272
+ if (store) {
273
+ try {
274
+ if (store.pendingQueueTaskCount(mesh.id) === 0) continue;
275
+ } catch { /* fall through and let triggerMeshQueue decide */ }
276
+ }
277
+ try {
278
+ await triggerMeshQueue(components, mesh.id);
279
+ } catch (e: any) {
280
+ LOG.warn('MeshReconcile', `Pending-claim recovery trigger failed for mesh ${mesh.id}: ${e?.message || e}`);
281
+ }
282
+ }
283
+
254
284
  // ── PHASE 2: inject into live CLI coordinators on this daemon ──────────────
255
285
  const coordinators = findLiveCoordinators(components);
256
286
  if (coordinators.length === 0) {
@@ -504,6 +504,19 @@ export class MeshRuntimeStore {
504
504
  return row?.count ?? 0;
505
505
  }
506
506
 
507
+ /**
508
+ * O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
509
+ * indexed status column, so it avoids JSON.parse-ing every queue row — used as a
510
+ * cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
511
+ */
512
+ pendingQueueTaskCount(meshId: string): number {
513
+ const row = this.db.prepare(`
514
+ SELECT COUNT(*) as count FROM mesh_queue
515
+ WHERE mesh_id = ? AND status = 'pending'
516
+ `).get(meshId) as { count: number } | undefined;
517
+ return row?.count ?? 0;
518
+ }
519
+
507
520
  /**
508
521
  * Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
509
522
  * the tie-break winner among nodes tied at the least load.