fix(coding-agent): render absolute mtimes in the system-prompt workspace tree

The workspace tree shown in the system prompt renders per-entry modification
times as render-time relative ages ("9m ago") computed from Date.now() on
every build. Those strings drift between sessions ("9m ago" -> "10m ago",
"59m ago" -> "1h ago") while the files themselves are unchanged. Because the
tree sits ahead of the (multi-thousand-token) tool block and KV cache is
contextual, that one early change invalidates the cached prefix for everything
after it, forcing a full prompt re-prefill on the first request of every new
session — even when nothing in the workspace actually changed.

Fix: render a deterministic absolute UTC timestamp (YYYY-MM-DD HH:MM) derived
purely from the file's mtime for the cached system-prompt tree, so the rendered
block is byte-identical across sessions and only changes when a file actually
changes. Scoped via a new internal AssembleOptions.ageMode:
  - buildWorkspaceTree (cached system prompt) -> "absolute"
  - buildDirectoryTree (read-tool output, not cached) -> "relative" (unchanged)
renderNode now takes a per-pass age formatter instead of reading Date.now()
directly.

Measured on a local llama.cpp server (single user, prompt cache on): with a
file whose age ticks between two back-to-back sessions, the unpatched build
re-prefills the full prefix on session 2 (27,124 prompt tokens, 46s); with this
change session 2 is a cache hit (13 tokens, 2s). Existing tests are unaffected
(they assert on filenames/order/elision, not on age strings); two regression
tests added.
This commit is contained in:
Steve Pinkham
2026-06-14 19:17:24 -04:00
parent 37d356f7bc
commit db3af3eff8
2 changed files with 72 additions and 6 deletions
+46 -6
View File
@@ -75,6 +75,9 @@ export async function buildDirectoryTree(cwd: string, options: BuildDirectoryTre
rootLimit,
lineCap: options.lineCap === undefined ? null : options.lineCap,
nativeTruncated,
// Tool output (read tool directory listing), not a cached prefix —
// the human-friendly relative "ago" is appropriate here.
ageMode: "relative",
});
}
@@ -99,6 +102,10 @@ export async function buildWorkspaceTree(cwd: string, options: BuildWorkspaceTre
rootLimit: WORKSPACE_DEFAULTS.perDirLimit,
lineCap: WORKSPACE_DEFAULTS.lineCap,
nativeTruncated: result.truncated,
// This tree is embedded in the cached system prompt. Render absolute
// mtimes so the block is byte-identical across sessions and does not
// bust the prompt cache (a relative "Nm ago" drifts every build).
ageMode: "absolute",
});
return { ...tree, agentsMdFiles: result.agentsMdFiles };
} catch {
@@ -132,6 +139,13 @@ interface AssembleOptions {
rootLimit: number | null;
lineCap: number | null;
nativeTruncated: boolean;
/**
* How per-entry modification times are rendered.
* - "relative": render-time "Nm ago" (fine for tool output).
* - "absolute": deterministic UTC timestamp (prompt-cache-stable; used for
* the system-prompt workspace tree). See {@link makeAgeFormatter}.
*/
ageMode: "relative" | "absolute";
}
function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: AssembleOptions): DirectoryTree {
@@ -187,7 +201,7 @@ function assembleTree(rootPath: string, entries: readonly GlobMatch[], opts: Ass
}
const rawLines: RenderedLine[] = [];
renderNode(root, Date.now(), rawLines);
renderNode(root, makeAgeFormatter(opts.ageMode), rawLines);
const { lines, elidedCount } = applyLineCap(rawLines, opts.lineCap);
return {
@@ -202,7 +216,33 @@ function byRecency(a: Node, b: Node): number {
return b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
}
function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
/**
* Build the per-node age formatter for a single render pass.
*
* - "relative": a render-time "Nm ago" string (computed once from `Date.now()`).
* Used for tool output that is not part of any cached prefix.
* - "absolute": a deterministic UTC `YYYY-MM-DD HH:MM` derived purely from the
* file's mtime. Used for the system-prompt workspace tree so the rendered
* block stays byte-identical across sessions. A relative age is recomputed on
* every build, so two sessions seconds apart differ ("9m ago" → "10m ago");
* because KV cache is contextual, that early change invalidates the cache for
* everything after the tree — including the multi-thousand-token tool block —
* forcing a full prompt re-prefill on every new session. An absolute mtime
* only changes when the file itself changes, which is the correct trigger.
*/
function makeAgeFormatter(mode: "relative" | "absolute"): (mtimeMs: number) => string {
if (mode === "absolute") return formatMtimeStable;
const nowMs = Date.now();
return (mtimeMs: number) => formatAge(Math.max(0, Math.floor((nowMs - mtimeMs) / 1000)));
}
/** Deterministic, render-time-independent timestamp: UTC `YYYY-MM-DD HH:MM`. */
function formatMtimeStable(mtimeMs: number): string {
if (!mtimeMs) return "";
return new Date(mtimeMs).toISOString().slice(0, 16).replace("T", " ");
}
function renderNode(node: Node, formatNodeAge: (mtimeMs: number) => string, out: RenderedLine[]): void {
if (node.depth === 0) {
out.push({ label: node.name, depth: 0, isRoot: true });
} else {
@@ -213,26 +253,26 @@ function renderNode(node: Node, nowMs: number, out: RenderedLine[]): void {
depth: node.depth,
isRoot: false,
size: node.isDir ? undefined : formatBytes(node.size),
age: formatAge(Math.max(0, Math.floor((nowMs - node.mtimeMs) / 1000))),
age: formatNodeAge(node.mtimeMs),
});
}
if (node.droppedCount === 0) {
for (const child of node.children) renderNode(child, nowMs, out);
for (const child of node.children) renderNode(child, formatNodeAge, out);
return;
}
// Layout: recent children, then "… N more" marker, then the oldest child.
const recent = node.children.slice(0, -1);
const oldest = node.children.at(-1);
for (const child of recent) renderNode(child, nowMs, out);
for (const child of recent) renderNode(child, formatNodeAge, out);
const childDepth = node.depth + 1;
out.push({
label: `${" ".repeat(childDepth)}- … ${node.droppedCount} more`,
depth: childDepth,
isRoot: false,
});
if (oldest) renderNode(oldest, nowMs, out);
if (oldest) renderNode(oldest, formatNodeAge, out);
}
/**
@@ -194,4 +194,30 @@ describe("buildWorkspaceTree", () => {
expect(tree.rendered).not.toContain(".hidden");
expect(tree.agentsMdFiles).toEqual(["src/AGENTS.md"]);
});
it("renders prompt-cache-stable absolute mtimes (not render-time relative ages)", async () => {
const cwd = await makeTempDir();
// Fixed mtime in the past so the absolute render is deterministic.
const fixedMtime = Date.UTC(2025, 0, 2, 3, 4, 0); // 2025-01-02 03:04 UTC
await writeFileWithMtime(path.join(cwd, "stable.txt"), "x", fixedMtime);
const first = await buildWorkspaceTree(cwd);
// A relative "ago" age would drift with the wall clock between builds;
// an absolute mtime must not. Two builds of an unchanged tree must be
// byte-identical so the system-prompt prefix stays cacheable.
const second = await buildWorkspaceTree(cwd);
expect(first.rendered).toBe(second.rendered);
expect(first.rendered).toContain("2025-01-02 03:04");
expect(first.rendered).not.toContain("ago");
});
it("keeps relative ages for buildDirectoryTree (tool output, not cached)", async () => {
const cwd = await makeTempDir();
await writeFileWithMtime(path.join(cwd, "recent.txt"), "x", Date.now() - 5 * 60_000);
const tree = await buildDirectoryTree(cwd, { maxDepth: 1 });
expect(tree.rendered).toContain("ago");
});
});