Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions packages/cli/src/running-task-adaptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { pochiConfig } from "@getpochi/common/configuration";
import type { McpHub } from "@getpochi/common/mcp-utils";
import {
FileStateCache,
isVirtualPath,
maybePersistToolResult,
resolvePath,
} from "@getpochi/common/tool-utils";
import {
type ValidCustomAgentFile,
Expand Down Expand Up @@ -150,6 +152,14 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor {
logger.warn({ taskId, error }, "Task execution failed");
}

hydrateFileReadHistory(taskId: string, paths: string[]) {
this.getFileStateCache(taskId).hydrateReadHistory(
paths
.filter((filePath) => !isVirtualPath(filePath))
.map((filePath) => resolvePath(filePath, this.cwd)),
);
}

clearFileStateCache(taskId: string) {
this.fileStateCaches.get(taskId)?.markAllAsWritten();
}
Expand Down Expand Up @@ -181,13 +191,23 @@ export class CliRunningTaskAdaptor implements RunningTaskAdaptor {
(sourceTaskId === this.parentTaskId
? this.parentFileStateCache
: undefined);
const target = new FileStateCache();
const target = existingTarget ?? new FileStateCache();
if (source) {
for (const [key, value] of source) {
target.set(key, { ...value });
}
const history = source.getReadHistorySnapshot();
if (history.hydrated) {
target.hydrateReadHistory(history.paths);
} else {
for (const filePath of history.paths) {
target.recordRead(filePath);
}
}
}
if (!existingTarget) {
this.fileStateCaches.set(targetTaskId, target);
}
this.fileStateCaches.set(targetTaskId, target);
}

private getFileStateCache(taskId: string) {
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/task-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
} from "@getpochi/common/message-utils";
import {
FileStateCache,
isVirtualPath,
maybePersistToolResult,
resolvePath,
} from "@getpochi/common/tool-utils";
import {
type ValidCustomAgentFile,
Expand All @@ -34,6 +36,7 @@ import {
type LiveKitStore,
type Message,
type Task,
collectPreviouslyReadFilePaths,
processContentOutput,
} from "@getpochi/livekit";
import { LiveChatKit } from "@getpochi/livekit/node";
Expand Down Expand Up @@ -209,6 +212,7 @@ export class TaskRunner {
private backgroundJobManager: BackgroundJobManager;
private fileSystem: FileSystem;
private customAgent?: CustomAgent;
private fileReadHistoryHydrated = false;

private attemptCompletionHook?: string;
private asyncWaitTimeoutInMs: number;
Expand Down Expand Up @@ -750,6 +754,8 @@ export class TaskRunner {
toolCall: ToolUIPart<UITools>,
envs: Record<string, string> | undefined,
): Promise<unknown> {
this.hydrateFileReadHistory();

try {
return await processContentOutput(
this.blobStore,
Expand All @@ -769,6 +775,17 @@ export class TaskRunner {
}
}

private hydrateFileReadHistory() {
if (this.fileReadHistoryHydrated) return;

this.toolCallOptions.fileStateCache.hydrateReadHistory(
collectPreviouslyReadFilePaths(this.chat.messages)
.filter((filePath) => !isVirtualPath(filePath))
.map((filePath) => resolvePath(filePath, this.cwd)),
);
this.fileReadHistoryHydrated = true;
}

// Helper method to run the command
private runAttemptCompletionHook(
command: string,
Expand Down
78 changes: 75 additions & 3 deletions packages/common/src/tool-utils/__tests__/file-state-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,67 @@ describe("FileStateCache", () => {

await expect(
checkStaleness(cache, "/tmp/file.txt", async () => 1234, "editing"),
).rejects.toThrow("File has not been read yet");
).rejects.toThrow("No current read snapshot is available");
});

it("throws when writing a file that was never read but exists on disk", async () => {
const cache = new FileStateCache();

await expect(
checkStaleness(cache, "/tmp/file.txt", async () => 1234, "writing"),
).rejects.toThrow("File has not been read yet");
).rejects.toThrow("No current read snapshot is available");
});

it("explains when the file was read earlier but its current snapshot is unavailable", async () => {
const cache = new FileStateCache();
cache.recordRead("/tmp/file.txt");

await expect(
checkStaleness(cache, "/tmp/file.txt", async () => 1234, "writing"),
).rejects.toThrow(
"File was read earlier in this task, but no current read snapshot is available",
);
});

it("explains when the available task history has no successful read", async () => {
const cache = new FileStateCache();
cache.hydrateReadHistory([]);

await expect(
checkStaleness(cache, "/tmp/file.txt", async () => 1234, "writing"),
).rejects.toThrow("File has not been read in the available task history");
});

it("normalizes hydrated read history and clears it with the cache", () => {
const cache = new FileStateCache();
cache.hydrateReadHistory(["/tmp/src/../file.txt"]);

expect(cache.getReadHistoryState("/tmp/file.txt")).toBe("read");
expect(cache.getReadHistoryState("/tmp/other.txt")).toBe("not-read");

cache.clear();

expect(cache.getReadHistoryState("/tmp/file.txt")).toBe("unknown");
});

it("records successful reads even when there is no text snapshot to cache", async () => {
const cache = new FileStateCache();

await withReadFileCache({
cache,
path: "binary.png",
cwd: "/tmp",
startLine: undefined,
endLine: undefined,
getMtime: async () => 1,
doRead: async () => ({
result: { content: "image" },
fileCacheContent: null,
}),
});

expect(cache.getReadHistoryState("/tmp/binary.png")).toBe("read");
expect(cache.has("/tmp/binary.png")).toBe(false);
});

it("allows writing a new file that does not exist on disk and was never read", async () => {
Expand Down Expand Up @@ -195,7 +247,26 @@ describe("withFileStateCacheGuard", () => {
fileCacheContent: "new content",
}),
}),
).rejects.toThrow("File has not been read yet");
).rejects.toThrow("No current read snapshot is available");
});

it("normalizes historical paths before checking the resolved target path", async () => {
const cache = new FileStateCache();
cache.recordRead("/tmp/src/../file.txt");

await expect(
withFileStateCacheGuard({
cache,
path: "src/../file.txt",
cwd: "/tmp",
getMtime: async () => 1000,
operation: "writing",
doWork: async () => ({
result: { success: true as const },
fileCacheContent: "new content",
}),
}),
).rejects.toThrow("File was read earlier in this task");
});

it("allows writing a brand-new file that does not yet exist on disk", async () => {
Expand All @@ -216,5 +287,6 @@ describe("withFileStateCacheGuard", () => {
}),
}),
).resolves.toEqual({ success: true });
expect(cache.getReadHistoryState("/tmp/brand-new.txt")).toBe("read");
});
});
77 changes: 62 additions & 15 deletions packages/common/src/tool-utils/file-state-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const DEFAULT_MAX_ENTRIES = 100;
const DEFAULT_MAX_SIZE_BYTES = 25 * 1024 * 1024;

/**
* LRU cache that tracks what file content the model has "seen".
* Cache that tracks both the model's current file snapshots and which files
* have become known through a successful read or write during the task.
*
* this serves three purposes:
*
Expand All @@ -46,10 +47,15 @@ const DEFAULT_MAX_SIZE_BYTES = 25 * 1024 * 1024;
* 3. **Post-write cache update**: After a successful edit or write, the cache
* is updated with the new content and mtime so subsequent reads can dedup.
*
* The cache uses an LRU eviction policy with both entry-count and byte-size limits.
* Current snapshots use an LRU eviction policy with both entry-count and
* byte-size limits. Read history is lightweight provenance and is retained
* independently, so evicting a snapshot does not erase the fact that a file
* was read earlier.
*/
export class FileStateCache {
private readonly entries: Map<string, IFileState> = new Map();
private readonly readHistory = new Set<string>();
private readHistoryHydrated = false;
private currentSizeBytes = 0;

/**
Expand Down Expand Up @@ -112,17 +118,45 @@ export class FileStateCache {

clear(): void {
this.entries.clear();
this.readHistory.clear();
this.readHistoryHydrated = false;
this.currentSizeBytes = 0;
}

recordRead(key: string): void {
this.readHistory.add(this.normalizeKey(key));
}

/** Merge read paths recovered from persisted task history. */
hydrateReadHistory(keys: readonly string[]): void {
for (const key of keys) {
this.recordRead(key);
}
this.readHistoryHydrated = true;
}

getReadHistoryState(key: string): "read" | "not-read" | "unknown" {
if (this.readHistory.has(this.normalizeKey(key))) {
return "read";
}
return this.readHistoryHydrated ? "not-read" : "unknown";
}

getReadHistorySnapshot(): { paths: string[]; hydrated: boolean } {
return {
paths: [...this.readHistory],
hydrated: this.readHistoryHydrated,
};
}

/**
* Downgrade every cached entry to "written" state (`fromWrite: true`).
*
* Used when the read tool_results that populated the cache are about to
* leave the conversation — a compaction summary, or a retry that strips a
* completed read. Keeping the entries preserves the edit/write staleness
* guard (so a later edit of an already-read file is not falsely rejected
* with "File has not been read yet"), while `fromWrite: true` stops them
* for lacking a current read snapshot), while `fromWrite: true` stops them
* from producing a "File unchanged" dedup stub that would dangle onto a
* tool_result no longer present in the conversation.
*/
Expand Down Expand Up @@ -221,12 +255,17 @@ export async function checkStaleness(
const cachedState = cache.get(resolvedPath);
if (!cachedState) {
const currentMtime = await getMtime(resolvedPath);
// If the file exists on disk but was never read, require a read first.
// If the file exists on disk but has no current snapshot, require a read first.
// A missing mtime means the file doesn't exist yet, so creating it is fine.
if (currentMtime !== undefined) {
throw new Error(
`File has not been read yet. Please read the file before ${operation} it.`,
);
const readHistoryState = cache.getReadHistoryState(resolvedPath);
const message =
readHistoryState === "read"
? `File was read earlier in this task, but no current read snapshot is available. Please read the file again before ${operation} it.`
: readHistoryState === "not-read"
? `File has not been read in the available task history. Please read the file before ${operation} it.`
: `No current read snapshot is available for this file. Please read the file before ${operation} it.`;
throw new Error(message);
}
return;
}
Expand Down Expand Up @@ -308,14 +347,18 @@ export async function withFileStateCacheGuard<T>(opts: {

const { result, fileCacheContent } = await doWork(resolvedPath);

// --- Update cache with new content ---
if (!isVirtual && cache && fileCacheContent !== null) {
await updateCacheAfterWrite(
cache,
resolvedPath,
fileCacheContent,
getMtime,
);
// A successful write leaves the model knowing the resulting file content,
// so treat it the same as a successful read for historical messaging.
if (!isVirtual && cache) {
cache.recordRead(resolvedPath);
if (fileCacheContent !== null) {
await updateCacheAfterWrite(
cache,
resolvedPath,
fileCacheContent,
getMtime,
);
}
}

return result;
Expand Down Expand Up @@ -406,6 +449,10 @@ export async function withReadFileCache<T>(opts: {
const { result, fileCacheContent, fileCacheIsTruncated } =
await doRead(resolvedPath);

if (shouldCache) {
cache.recordRead(resolvedPath);
}

// --- Populate cache ---
// Store what the model has "seen" so that future reads can dedup,
// and edit/write tools can detect external modifications.
Expand Down
6 changes: 6 additions & 0 deletions packages/common/src/vscode-webui-bridge/webview-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ const VSCodeHostStub = {
clearFileStateCache: (_taskId: string): Promise<void> => {
return Promise.resolve();
},
hydrateFileReadHistory: (
_taskId: string,
_paths: string[],
): Promise<void> => {
return Promise.resolve();
},
readRecentFilesForCompact: (_taskId: string) => {
return Promise.resolve([]);
},
Expand Down
3 changes: 3 additions & 0 deletions packages/common/src/vscode-webui-bridge/webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ export interface VSCodeHostApi {
*/
clearFileStateCache(taskId: string): Promise<void>;

/** Restore successful readFile paths from the available task history. */
hydrateFileReadHistory(taskId: string, paths: string[]): Promise<void>;

/**
* Read recent file state cache entries for the given task ID.
* Used by compaction to keep recently read file contents visible after
Expand Down
Loading
Loading