@adhdev/daemon-core 0.9.82-rc.302 → 0.9.82-rc.304

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.302",
3
+ "version": "0.9.82-rc.304",
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.302",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.304",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -966,6 +966,26 @@ function finalizeMeshNodeStatus(args: {
966
966
  );
967
967
  }
968
968
 
969
+ // Reads a positive integer timeout (ms) from an env var, clamped to [1s, 120s];
970
+ // falls back to the default when unset or out of range. Lets slow cross-machine
971
+ // peers (e.g. a TURN-relayed Windows daemon whose git_status RTT is 10-18s) be
972
+ // tuned without a rebuild.
973
+ function readMeshTimeoutEnvMs(name: string, defaultMs: number): number {
974
+ const raw = process.env[name]?.trim();
975
+ if (!raw) return defaultMs;
976
+ const parsed = Number.parseInt(raw, 10);
977
+ if (Number.isFinite(parsed) && parsed >= 1_000 && parsed <= 120_000) return parsed;
978
+ return defaultMs;
979
+ }
980
+
981
+ // Direct-peer git_status probe timeout for the dashboard's requireDirectPeerTruth
982
+ // bootstrap. The previous hard-coded 8s/12s were shorter than the real P2P
983
+ // round-trip to slow (often TURN-relayed) peers, so such a node was permanently
984
+ // marked unavailable and blocked the whole mesh graph. Default raised to 25s
985
+ // (still under the P2P REQUEST_TIMEOUT of 30s) and made env-overridable.
986
+ const MESH_DIRECT_PROBE_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_TIMEOUT_MS', 25_000);
987
+ const MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS = readMeshTimeoutEnvMs('MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS', 25_000);
988
+
969
989
  async function probeRemoteMeshGitStatus(args: {
970
990
  dispatchMeshCommand?: (daemonId: string, cmd: string, args: Record<string, unknown>) => Promise<unknown>;
971
991
  daemonId: string;
@@ -1057,7 +1077,7 @@ async function hydrateInlineMeshDirectTruth(args: {
1057
1077
  dispatchMeshCommand: args.dispatchMeshCommand,
1058
1078
  daemonId,
1059
1079
  workspace,
1060
- timeoutMs: 8_000,
1080
+ timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
1061
1081
  });
1062
1082
  if (remoteGit) {
1063
1083
  recordInlineMeshDirectGitTruth(node, remoteGit, 'selected_coordinator_mesh_p2p_git');
@@ -8574,7 +8594,7 @@ export class DaemonCommandRouter {
8574
8594
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
8575
8595
  daemonId,
8576
8596
  workspace,
8577
- timeoutMs: 8000,
8597
+ timeoutMs: MESH_DIRECT_PROBE_TIMEOUT_MS,
8578
8598
  });
8579
8599
  if (remoteGit) {
8580
8600
  status.git = remoteGit;
@@ -8600,7 +8620,7 @@ export class DaemonCommandRouter {
8600
8620
  dispatchMeshCommand: this.deps.dispatchMeshCommand,
8601
8621
  daemonId,
8602
8622
  workspace,
8603
- timeoutMs: 12000,
8623
+ timeoutMs: MESH_DIRECT_PROBE_RETRY_TIMEOUT_MS,
8604
8624
  });
8605
8625
  if (remoteGit) {
8606
8626
  status.git = remoteGit;
@@ -10,6 +10,18 @@ const execFileAsync = promisify(execFile);
10
10
  const DEFAULT_TIMEOUT_MS = 5_000;
11
11
  const DEFAULT_MAX_BUFFER = 1024 * 1024;
12
12
 
13
+ /**
14
+ * Timeout for status-collection git commands (status/log/submodule/stash/fetch).
15
+ * The default 5s is fine for porcelain status, but on Windows the `git` subprocess
16
+ * spawn itself is pathologically slow (measured: `submodule status` ~3.5s, cold
17
+ * `log -1` ~4.2s) and overlaps with refreshUpstream fetches — a single command
18
+ * routinely exceeds 5s, which previously collapsed the whole status to all-null and
19
+ * dropped the node from the mesh graph. Give the collection path a much larger
20
+ * budget so a slow-but-healthy repo never reads as "not a git repo". Windows gets a
21
+ * larger budget than POSIX because the spawn cost is OS-specific, not repo-specific.
22
+ */
23
+ export const GIT_STATUS_TIMEOUT_MS = process.platform === 'win32' ? 30_000 : 20_000;
24
+
13
25
  export interface GitExecutorOptions {
14
26
  timeoutMs?: number;
15
27
  maxBuffer?: number;
@@ -1,9 +1,31 @@
1
1
  import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
- import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
2
+ import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
3
  import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
4
4
 
5
5
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
6
6
 
7
+ /**
8
+ * Last successfully-collected status per workspace, used to survive a transient git
9
+ * failure (timeout, slow Windows spawn under load, a momentary lock) WITHOUT dropping
10
+ * the node out of the mesh graph. A genuine "not a git repository" answer is NOT a
11
+ * transient failure — it never populates this cache and always reports isGitRepo:false.
12
+ */
13
+ const lastKnownGoodStatus = new Map<string, GitRepoStatus>();
14
+
15
+ /** Test seam: clear the last-known-good status cache between cases. */
16
+ export function __resetGitStatusCacheForTests(): void {
17
+ lastKnownGoodStatus.clear();
18
+ }
19
+
20
+ /**
21
+ * git failure reasons that are transient/environmental rather than a real statement
22
+ * that the workspace is not a repo. On these we prefer the last-known-good status so a
23
+ * single slow git call cannot make a healthy node vanish from the graph.
24
+ */
25
+ function isTransientGitFailure(error: GitCommandError): boolean {
26
+ return error.reason === 'timeout' || error.reason === 'git_command_failed';
27
+ }
28
+
7
29
  export interface GitStatusOptions {
8
30
  timeoutMs?: number;
9
31
  /** When true, include submodule status in the result. Defaults to true. */
@@ -35,9 +57,50 @@ export async function getGitRepoStatus(
35
57
  ): Promise<GitRepoStatus> {
36
58
  const lastCheckedAt = Date.now();
37
59
  const includeSubmodules = options.includeSubmodules !== false;
60
+ // Status collection fans out into several git subprocesses (status, head, stash,
61
+ // submodule, optionally fetch). On Windows the per-spawn cost alone can exceed the
62
+ // 5s default, so unless the caller pinned a timeout, give the whole collection path
63
+ // the larger status budget. A caller that explicitly sets timeoutMs (e.g. a test
64
+ // injecting 1ms to exercise the transient-failure path) is respected.
65
+ const effectiveOptions: GitStatusOptions =
66
+ options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
38
67
 
39
68
  try {
40
- const repo = await resolveGitRepository(workspace, options);
69
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
70
+ const status = await collectGitRepoStatus(repo, includeSubmodules, lastCheckedAt, effectiveOptions);
71
+ lastKnownGoodStatus.set(workspace, status);
72
+ return status;
73
+ } catch (error) {
74
+ const gitError = error instanceof GitCommandError
75
+ ? error
76
+ : new GitCommandError('git_command_failed', 'Failed to read Git status', { cause: error });
77
+
78
+ // A transient/environmental failure (timeout, slow-spawn-under-load) must NOT make
79
+ // a healthy node lose its repo identity and drop out of the mesh graph. Prefer the
80
+ // last status we successfully collected for this workspace, re-stamped as stale.
81
+ if (isTransientGitFailure(gitError)) {
82
+ const cached = lastKnownGoodStatus.get(workspace);
83
+ if (cached) {
84
+ return {
85
+ ...cached,
86
+ lastCheckedAt,
87
+ upstreamStatus: 'unavailable',
88
+ error: gitError.stderr || gitError.message,
89
+ reason: gitError.reason,
90
+ };
91
+ }
92
+ }
93
+
94
+ return emptyStatus(workspace, lastCheckedAt, gitError);
95
+ }
96
+ }
97
+
98
+ async function collectGitRepoStatus(
99
+ repo: ResolvedGitRepo,
100
+ includeSubmodules: boolean,
101
+ lastCheckedAt: number,
102
+ options: GitStatusOptions,
103
+ ): Promise<GitRepoStatus> {
41
104
  let parsed = await readPorcelainStatus(repo, options);
42
105
  let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
43
106
 
@@ -89,16 +152,6 @@ export async function getGitRepoStatus(
89
152
  submodules,
90
153
  ...(daemonBuildBehind ? { daemonBuildBehind } : {}),
91
154
  };
92
- } catch (error) {
93
- if (error instanceof GitCommandError) {
94
- return emptyStatus(workspace, lastCheckedAt, error);
95
- }
96
- return emptyStatus(
97
- workspace,
98
- lastCheckedAt,
99
- new GitCommandError('git_command_failed', 'Failed to read Git status', { cause: error }),
100
- );
101
- }
102
155
  }
103
156
 
104
157
  /**
@@ -507,7 +560,12 @@ async function getSubmoduleStatuses(
507
560
  if (!repo.repoRoot) return [];
508
561
 
509
562
  try {
510
- const result = await runGit(repo, ['submodule', 'status', '--recursive'], options);
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);
511
569
  const submodules = parseSubmoduleStatusOutput(result.stdout, repo.repoRoot, options.submoduleIgnorePaths);
512
570
  await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
513
571
  return submodules;