@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.
- package/dist/index.js +641 -535
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +652 -546
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-runtime-store.d.ts +6 -0
- package/package.json +2 -2
- package/src/cli-adapters/resolve-executable.ts +46 -1
- package/src/commands/router.ts +32 -0
- package/src/detection/cli-detector.ts +28 -9
- 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.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.
|
|
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
|
|
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
|
}
|
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
|
|
@@ -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
|
|
101
|
-
|
|
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
|
|
152
|
-
|
|
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 = [
|
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.
|