mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
fix(daemon): invalidate Gemini resolver cache on binary replacement
This commit is contained in:
parent
c274e91689
commit
b6f0c562b3
2 changed files with 122 additions and 11 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue