@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.
- package/dist/index.js +107 -29
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +107 -29
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/package.json +2 -2
- package/src/commands/router.ts +32 -0
- package/src/git/git-status.ts +122 -41
- package/src/mesh/mesh-events-coordinator.ts +5 -1
- package/src/mesh/mesh-reconcile-loop.ts +31 -1
- package/src/mesh/mesh-runtime-store.ts +13 -0
|
@@ -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.
|
|
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.
|
|
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",
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
package/src/git/git-status.ts
CHANGED
|
@@ -560,13 +560,24 @@ async function getSubmoduleStatuses(
|
|
|
560
560
|
if (!repo.repoRoot) return [];
|
|
561
561
|
|
|
562
562
|
try {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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.
|