fix(daemon): invalidate Gemini resolver cache on binary replacement

This commit is contained in:
李冠辰 2026-05-31 12:55:47 +08:00
parent c274e91689
commit b6f0c562b3
2 changed files with 122 additions and 11 deletions

View file

@ -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 `<AGENT>_BIN` override is intentionally NOT cached so
// clearing the env reliably falls back to auto-pick (#1007 round-2 P2).
const versionAwareCache = new Map<string, string>();
const versionAwareCache = new Map<string, VersionAwareCacheEntry>();
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;
}
}

View file

@ -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)', () => {