fix(rules-injector): bound parsed rule cache
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -3,7 +3,7 @@ import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
|||||||
import { resolveSessionEventID } from "../../shared/event-session-id";
|
import { resolveSessionEventID } from "../../shared/event-session-id";
|
||||||
import { getRuleInjectionFilePath } from "./output-path";
|
import { getRuleInjectionFilePath } from "./output-path";
|
||||||
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
import { createSessionCacheStore, createSessionRuleScanCacheStore } from "./cache";
|
||||||
import { createRuleInjectionProcessor } from "./injector";
|
import { clearParsedRuleCache, createRuleInjectionProcessor } from "./injector";
|
||||||
import { clearProjectRootCache } from "./project-root-finder";
|
import { clearProjectRootCache } from "./project-root-finder";
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
@@ -53,6 +53,7 @@ export function createRulesInjectorHook(
|
|||||||
function clearSessionState(sessionID: string): void {
|
function clearSessionState(sessionID: string): void {
|
||||||
clearSessionCache(sessionID);
|
clearSessionCache(sessionID);
|
||||||
clearSessionRuleScanCache(sessionID);
|
clearSessionRuleScanCache(sessionID);
|
||||||
|
clearParsedRuleCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolExecuteAfter = async (
|
const toolExecuteAfter = async (
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import * as os from "node:os";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { RULES_INJECTOR_STORAGE } from "./constants";
|
import { RULES_INJECTOR_STORAGE } from "./constants";
|
||||||
import { createRuleInjectionProcessor } from "./injector";
|
import { clearParsedRuleCache, createRuleInjectionProcessor, getParsedRuleCacheStats } from "./injector";
|
||||||
|
|
||||||
type StatSnapshot = { mtimeMs: number; size: number };
|
type StatSnapshot = { mtimeMs: number; size: number };
|
||||||
|
|
||||||
@@ -55,11 +55,11 @@ async function createProcessor(projectRoot: string): Promise<{
|
|||||||
}
|
}
|
||||||
return cache;
|
return cache;
|
||||||
},
|
},
|
||||||
readFileSync: (filePath: fs.PathOrFileDescriptor, options?: Parameters<typeof originalReadFileSync>[1]) => {
|
readFileSync: (filePath: string, encoding: "utf-8") => {
|
||||||
if (filePath === trackedRulePath) {
|
if (filePath === trackedRulePath) {
|
||||||
trackedReadFileCount += 1;
|
trackedReadFileCount += 1;
|
||||||
}
|
}
|
||||||
return originalReadFileSync(filePath, options as never);
|
return originalReadFileSync(filePath, encoding);
|
||||||
},
|
},
|
||||||
statSync: (filePath: fs.PathLike) => {
|
statSync: (filePath: fs.PathLike) => {
|
||||||
if (filePath === trackedRulePath) {
|
if (filePath === trackedRulePath) {
|
||||||
@@ -98,6 +98,7 @@ describe("createRuleInjectionProcessor", () => {
|
|||||||
let ruleRealPath: string;
|
let ruleRealPath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
clearParsedRuleCache();
|
||||||
testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`);
|
testRoot = join(tmpdir(), `rules-injector-injector-${Date.now()}`);
|
||||||
projectRoot = join(testRoot, "project");
|
projectRoot = join(testRoot, "project");
|
||||||
homeRoot = join(testRoot, "home");
|
homeRoot = join(testRoot, "home");
|
||||||
@@ -125,6 +126,7 @@ describe("createRuleInjectionProcessor", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
clearParsedRuleCache();
|
||||||
if (fs.existsSync(testRoot)) {
|
if (fs.existsSync(testRoot)) {
|
||||||
rmSync(testRoot, { recursive: true, force: true });
|
rmSync(testRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -178,6 +180,25 @@ describe("createRuleInjectionProcessor", () => {
|
|||||||
expect(trackedReadFileCount).toBe(2);
|
expect(trackedReadFileCount).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not cache oversized parsed rule bodies", async () => {
|
||||||
|
// given
|
||||||
|
const largeBody = "x".repeat(70 * 1024);
|
||||||
|
writeFileSync(ruleFile, largeBody);
|
||||||
|
statSnapshots = [
|
||||||
|
{ mtimeMs: 1000, size: largeBody.length },
|
||||||
|
{ mtimeMs: 1000, size: largeBody.length },
|
||||||
|
];
|
||||||
|
const processor = await createProcessor(projectRoot);
|
||||||
|
|
||||||
|
// when
|
||||||
|
await processor.processFilePathForInjection(targetFile, "session-1", createOutput());
|
||||||
|
await processor.processFilePathForInjection(targetFile, "session-2", createOutput());
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(trackedReadFileCount).toBe(2);
|
||||||
|
expect(getParsedRuleCacheStats()).toEqual({ entries: 0, bodyBytes: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
it("does not save injected rules when all candidates are already cached", async () => {
|
it("does not save injected rules when all candidates are already cached", async () => {
|
||||||
// given
|
// given
|
||||||
const sessionID = `dirty-no-new-${Date.now()}`;
|
const sessionID = `dirty-no-new-${Date.now()}`;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ type DynamicTruncator = {
|
|||||||
) => Promise<{ result: string; truncated: boolean }>;
|
) => Promise<{ result: string; truncated: boolean }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuleFileReader = (path: string, encoding: "utf-8") => string;
|
||||||
|
|
||||||
interface ParsedRuleEntry {
|
interface ParsedRuleEntry {
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
size: number;
|
size: number;
|
||||||
@@ -42,8 +44,38 @@ interface ParsedRuleEntry {
|
|||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedRuleCacheStats {
|
||||||
|
entries: number;
|
||||||
|
bodyBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_PARSED_RULE_CACHE_ENTRIES = 256;
|
||||||
|
const MAX_PARSED_RULE_CACHE_BODY_BYTES = 64 * 1024;
|
||||||
const parsedRuleCache = new Map<string, ParsedRuleEntry>();
|
const parsedRuleCache = new Map<string, ParsedRuleEntry>();
|
||||||
|
|
||||||
|
export function clearParsedRuleCache(): void {
|
||||||
|
parsedRuleCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParsedRuleCacheStats(): ParsedRuleCacheStats {
|
||||||
|
let bodyBytes = 0;
|
||||||
|
for (const entry of parsedRuleCache.values()) {
|
||||||
|
bodyBytes += Buffer.byteLength(entry.body, "utf8");
|
||||||
|
}
|
||||||
|
return { entries: parsedRuleCache.size, bodyBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setParsedRuleCacheEntry(realPath: string, entry: ParsedRuleEntry): void {
|
||||||
|
if (Buffer.byteLength(entry.body, "utf8") > MAX_PARSED_RULE_CACHE_BODY_BYTES) return;
|
||||||
|
if (parsedRuleCache.size >= MAX_PARSED_RULE_CACHE_ENTRIES) {
|
||||||
|
const oldestRealPath = parsedRuleCache.keys().next().value;
|
||||||
|
if (oldestRealPath !== undefined) {
|
||||||
|
parsedRuleCache.delete(oldestRealPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parsedRuleCache.set(realPath, entry);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveFilePath(
|
function resolveFilePath(
|
||||||
workspaceDirectory: string,
|
workspaceDirectory: string,
|
||||||
path: string
|
path: string
|
||||||
@@ -59,7 +91,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||||
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
getSessionRuleScanCache?: (sessionID: string) => RuleScanCache;
|
||||||
ruleFinderOptions?: FindRuleFilesOptions;
|
ruleFinderOptions?: FindRuleFilesOptions;
|
||||||
readFileSync?: typeof readFileSync;
|
readFileSync?: RuleFileReader;
|
||||||
statSync?: typeof statSync;
|
statSync?: typeof statSync;
|
||||||
homedir?: typeof homedir;
|
homedir?: typeof homedir;
|
||||||
shouldApplyRule?: typeof shouldApplyRule;
|
shouldApplyRule?: typeof shouldApplyRule;
|
||||||
@@ -101,7 +133,7 @@ export function createRuleInjectionProcessor(deps: {
|
|||||||
|
|
||||||
const rawContent = readRuleFileSync(filePath, "utf-8");
|
const rawContent = readRuleFileSync(filePath, "utf-8");
|
||||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||||
parsedRuleCache.set(realPath, {
|
setParsedRuleCacheEntry(realPath, {
|
||||||
mtimeMs: stat.mtimeMs,
|
mtimeMs: stat.mtimeMs,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
metadata,
|
metadata,
|
||||||
|
|||||||
Reference in New Issue
Block a user