From b6f0c562b34878dd310d18dabd5ec1732f332302 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Sun, 31 May 2026 12:55:47 +0800 Subject: [PATCH] fix(daemon): invalidate Gemini resolver cache on binary replacement --- apps/daemon/src/runtimes/executables.ts | 91 ++++++++++++++++--- .../runtimes/version-aware-resolver.test.ts | 42 +++++++++ 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/apps/daemon/src/runtimes/executables.ts b/apps/daemon/src/runtimes/executables.ts index f69d15085..fdc4da109 100644 --- a/apps/daemon/src/runtimes/executables.ts +++ b/apps/daemon/src/runtimes/executables.ts @@ -1,4 +1,4 @@ -import { accessSync, constants, existsSync, statSync } from 'node:fs'; +import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'; import { delimiter } from 'node:path'; import path from 'node:path'; import { homedir } from 'node:os'; @@ -286,10 +286,7 @@ export function inspectAgentExecutableResolution( // falling back to first-match (#978). let pathResolvedPath: string | null = null; if (!configuredOverridePath && def.minVersion) { - const cached = versionAwareCache.get(def.id); - if (cached && existsSync(cached)) { - pathResolvedPath = cached; - } + pathResolvedPath = cachedVersionAwarePath(def.id); } if (!pathResolvedPath) { const candidates = [ @@ -324,13 +321,28 @@ export function inspectAgentExecutableResolution( const VERSION_PROBE_TIMEOUT_MS = 1_500; -// agent.id → resolved path that passed the version gate. Populated by -// `chooseExecutableByMinVersion`; consulted by +interface VersionAwareExecutableIdentity { + realpath: string; + dev: number; + ino: number; + mode: number; + size: number; + mtimeMs: number; + ctimeMs: number; +} + +interface VersionAwareCacheEntry { + path: string; + identity: VersionAwareExecutableIdentity; +} + +// agent.id → resolved path + file identity that passed the version gate. +// Populated by `chooseExecutableByMinVersion`; consulted by // `inspectAgentExecutableResolution` so the sync chat-spawn path sees // the same pick detection landed on. Only writes for the auto-pick path // — an explicit `_BIN` override is intentionally NOT cached so // clearing the env reliably falls back to auto-pick (#1007 round-2 P2). -const versionAwareCache = new Map(); +const versionAwareCache = new Map(); export function clearVersionAwareResolutionCache(agentId?: string): void { if (agentId === undefined) { @@ -340,6 +352,63 @@ export function clearVersionAwareResolutionCache(agentId?: string): void { } } +function readVersionAwareExecutableIdentity(filePath: string): VersionAwareExecutableIdentity | null { + try { + const realpath = realpathSync(filePath); + const stat = statSync(realpath); + if (!stat.isFile()) return null; + return { + realpath, + dev: stat.dev, + ino: stat.ino, + mode: stat.mode, + size: stat.size, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + }; + } catch { + return null; + } +} + +function versionAwareExecutableIdentityEquals( + a: VersionAwareExecutableIdentity, + b: VersionAwareExecutableIdentity, +): boolean { + return ( + a.realpath === b.realpath && + a.dev === b.dev && + a.ino === b.ino && + a.mode === b.mode && + a.size === b.size && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs + ); +} + +function cachedVersionAwarePath(agentId: string): string | null { + const cached = versionAwareCache.get(agentId); + if (!cached) return null; + const currentIdentity = readVersionAwareExecutableIdentity(cached.path); + if ( + !currentIdentity || + !versionAwareExecutableIdentityEquals(cached.identity, currentIdentity) + ) { + versionAwareCache.delete(agentId); + return null; + } + return cached.path; +} + +function rememberVersionAwarePath(agentId: string, selectedPath: string): void { + const identity = readVersionAwareExecutableIdentity(selectedPath); + if (!identity) { + versionAwareCache.delete(agentId); + return; + } + versionAwareCache.set(agentId, { path: selectedPath, identity }); +} + // Strict, anchored semver parse. Accepts a leading `v` and tolerates // trailing pre-release (`-rc.1`) / build metadata (`+build.5`) but // only major.minor.patch participates in comparison. Returns `null` @@ -396,8 +465,8 @@ export async function chooseExecutableByMinVersion( // (line ~390 below) and via `clearVersionAwareResolutionCache()`, so // a missing or relocated cached pick still gets re-probed at the // next launch. - const cached = versionAwareCache.get(def.id); - if (cached && existsSync(cached)) { + const cached = cachedVersionAwarePath(def.id); + if (cached) { return cached; } @@ -433,7 +502,7 @@ export async function chooseExecutableByMinVersion( for (const probe of probes) { const cmp = compareSemver(probe.version, def.minVersion); if (cmp !== null && cmp >= 0) { - versionAwareCache.set(def.id, probe.path); + rememberVersionAwarePath(def.id, probe.path); return probe.path; } } diff --git a/apps/daemon/tests/runtimes/version-aware-resolver.test.ts b/apps/daemon/tests/runtimes/version-aware-resolver.test.ts index 15609f9fe..64e3270a9 100644 --- a/apps/daemon/tests/runtimes/version-aware-resolver.test.ts +++ b/apps/daemon/tests/runtimes/version-aware-resolver.test.ts @@ -19,6 +19,7 @@ import { chmodSync, mkdirSync, mkdtempSync, + readFileSync, rmSync, writeFileSync, } from 'node:fs'; @@ -373,6 +374,47 @@ describe('chooseExecutableByMinVersion (#978: skip stale binaries that fail the rmSync(newDir, { recursive: true, force: true }); } }); + + fsTest('cached pick replaced in place with an older binary is re-probed instead of returned stale', async () => { + const cachedDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-cached-')); + const fallbackDir = mkdtempSync(join(tmpdir(), 'od-cache-replace-fallback-')); + try { + const cachedGemini = join(cachedDir, 'gemini'); + const fallbackGemini = join(fallbackDir, 'gemini'); + writeFileSync(cachedGemini, '0.40.1\n'); + writeFileSync(fallbackGemini, '0.41.0\n'); + chmodSync(cachedGemini, 0o755); + chmodSync(fallbackGemini, 0o755); + process.env.OD_AGENT_HOME = cachedDir; + process.env.PATH = `${cachedDir}${delimiter}${fallbackDir}`; + + const def = minimalAgentDef({ id: 'gemini', bin: 'gemini', minVersion: '0.30.0' }); + let probes = 0; + const runVersion = async (p: string) => { + probes += 1; + return readFileSync(p, 'utf8'); + }; + + const firstChosen = await chooseExecutableByMinVersion(def, {}, { runVersion }); + expect(firstChosen).toBe(cachedGemini); + const probesAfterCold = probes; + expect(probesAfterCold).toBeGreaterThanOrEqual(2); + + // Simulate a package-manager rewrite at the same visible path: + // the path still exists, but it now points at an older build that + // should not keep bypassing the min-version gate until daemon restart. + rmSync(cachedGemini); + writeFileSync(cachedGemini, '0.1.12 downgraded in-place\n'); + chmodSync(cachedGemini, 0o755); + + const secondChosen = await chooseExecutableByMinVersion(def, {}, { runVersion }); + expect(secondChosen).toBe(fallbackGemini); + expect(probes).toBeGreaterThan(probesAfterCold); + } finally { + rmSync(cachedDir, { recursive: true, force: true }); + rmSync(fallbackDir, { recursive: true, force: true }); + } + }); }); describe('inspectAgentExecutableResolution + minVersion cache wiring (#978)', () => {