From 9045484aea506c14aa2419b181a348d93a3d24ef Mon Sep 17 00:00:00 2001 From: zengLingbiao Date: Sun, 2 Aug 2026 18:17:48 +0800 Subject: [PATCH] fix(agents): bound project memory git remote lookup (#117346) * fix(agents): bound project memory git remote lookup * test(agents): prove project memory git timeout --------- Co-authored-by: Vincent Koc --- src/agents/project-memory-scope.test.ts | 33 +++++++++++++++++++++++++ src/agents/project-memory-scope.ts | 33 +++++++++++++------------ 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/agents/project-memory-scope.test.ts b/src/agents/project-memory-scope.test.ts index 9b5062fc47d3..0daff355ed43 100644 --- a/src/agents/project-memory-scope.test.ts +++ b/src/agents/project-memory-scope.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, describe, expect, it } from "vitest"; +import { withEnvAsync } from "../test-utils/env.js"; import { resolveProjectKey } from "./project-memory-scope.js"; const execFileAsync = promisify(execFile); @@ -94,4 +95,36 @@ describe("project memory scope", () => { Promise.all([resolveProjectKey(repo), resolveProjectKey(worktree)]), ).resolves.toEqual(["github.com/OpenClaw/OpenClaw", "github.com/OpenClaw/OpenClaw"]); }); + + it.runIf(process.platform !== "win32")( + "falls back to the path key when the git lookup hangs", + async () => { + const parent = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-project-hang-")); + cleanup.push(parent); + const fakeBinDir = path.join(parent, "fake-bin"); + await fs.mkdir(fakeBinDir); + const fakeGitPath = path.join(fakeBinDir, "git"); + await fs.writeFile( + fakeGitPath, + `#!${process.execPath}\nsetInterval(() => {}, 1000);\n`, + "utf-8", + ); + await fs.chmod(fakeGitPath, 0o755); + const repo = path.join(parent, "repo"); + await fs.mkdir(repo); + await git(repo, "init"); + await git(repo, "remote", "add", "origin", "https://github.com/OpenClaw/OpenClaw.git"); + + const started = Date.now(); + const key = await withEnvAsync( + { PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ""}` }, + () => resolveProjectKey(repo), + ); + const elapsed = Date.now() - started; + + expect(key).toBe(`path:${repo}`); + expect(elapsed).toBeLessThan(15_000); + }, + 20_000, + ); }); diff --git a/src/agents/project-memory-scope.ts b/src/agents/project-memory-scope.ts index 0f6d3c0d8db6..c3e4557235d0 100644 --- a/src/agents/project-memory-scope.ts +++ b/src/agents/project-memory-scope.ts @@ -1,10 +1,10 @@ -import { execFile } from "node:child_process"; import path from "node:path"; -import { promisify } from "node:util"; +import { runCommandWithTimeout } from "../process/exec.js"; import { parseGitUrl } from "./utils/git.js"; -const execFileAsync = promisify(execFile); const MAX_PROJECT_KEY_CACHE_ENTRIES = 128; +// Cheap git reads elsewhere bound at 4s (see detectGitRoot in infra/update-check.ts). +const GIT_CONFIG_TIMEOUT_MS = 4_000; const projectKeyByRepoRoot = new Map>(); @@ -32,20 +32,21 @@ function setBounded(map: Map, key: K, value: V, limit: number): void async function resolveUncachedProjectKey(repoRoot: string): Promise { try { - const { stdout } = await execFileAsync( - "git", - ["-C", repoRoot, "config", "--get", "remote.origin.url"], - { encoding: "utf8" }, + const result = await runCommandWithTimeout( + ["git", "-C", repoRoot, "config", "--get", "remote.origin.url"], + { timeoutMs: GIT_CONFIG_TIMEOUT_MS }, ); - const source = parseGitUrl(`git:${stdout.trim()}`); - if (source) { - // Userinfo is deliberately folded out so SSH and HTTPS clones converge. - // This accepts a rare same-host, same-path collision across distinct SSH - // accounts; the tradeoff is relevance bleed within one operator's store. - // Preserve remote path case so case-sensitive hosts fail closed. Providers - // with case-insensitive slugs may miss boosts/digests across casing variants, - // but folding paths could cross-inject memory between distinct repositories. - return escapeProjectKeyForAnnotation(`${source.host.toLowerCase()}/${source.path}`); + if (result.code === 0) { + const source = parseGitUrl(`git:${result.stdout.trim()}`); + if (source) { + // Userinfo is deliberately folded out so SSH and HTTPS clones converge. + // This accepts a rare same-host, same-path collision across distinct SSH + // accounts; the tradeoff is relevance bleed within one operator's store. + // Preserve remote path case so case-sensitive hosts fail closed. Providers + // with case-insensitive slugs may miss boosts/digests across casing variants, + // but folding paths could cross-inject memory between distinct repositories. + return escapeProjectKeyForAnnotation(`${source.host.toLowerCase()}/${source.path}`); + } } } catch { // Repositories without an origin intentionally use their canonical local root.