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

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.317",
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.317",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -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
@@ -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.