Skip to content
Merged
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
29 changes: 26 additions & 3 deletions packages/context-cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
Expand All @@ -8,6 +9,28 @@ import { cli_main, createCliProgram, handleCliFailure, isDirectCliInvocation } f
import { ContextError } from "../lib/errors.js";
import { ExitCode } from "../types/exitCode.js";

let executableTempRoot: string | undefined;

/**
* Fixtures that put fake host CLIs on PATH need a directory that allows
* execution. Hardened hosts mount the system temp directory noexec, so fall
* back to the ignored package-local .tmp/ when a probe script cannot run.
*/
function executableTmpdir(): string {
if (executableTempRoot !== undefined) return executableTempRoot;
const probeDir = mkdtempSync(join(tmpdir(), "ctx-cli-exec-probe-"));
try {
const probe = join(probeDir, "probe");
writeFileSync(probe, "#!/bin/sh\nexit 0\n", "utf8");
chmodSync(probe, 0o755);
executableTempRoot = spawnSync(probe).status === 0 ? tmpdir() : resolve(".tmp", "cli-exec-tests");
} finally {
rmSync(probeDir, { recursive: true, force: true });
}
mkdirSync(executableTempRoot, { recursive: true });
return executableTempRoot;
}

const CLI_MODULE = resolve(import.meta.dir, "..", "cli.ts");

async function runShell(
Expand Down Expand Up @@ -489,7 +512,7 @@ describe("CLI error handling", () => {
});

test("plugin install refreshes the manifest-version Codex cache before pruning old versions", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-versioned-cache-"));
const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-versioned-cache-"));
const pluginRoot = join(cwd, "plugins");
const codexHome = join(cwd, "codex-home");
const binDir = join(cwd, "bin");
Expand Down Expand Up @@ -533,7 +556,7 @@ describe("CLI error handling", () => {
});

test("plugin install continues available agents when codex is missing", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-partial-"));
const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-partial-"));
const pluginRoot = join(cwd, "plugins");
const binDir = join(cwd, "bin");
try {
Expand Down Expand Up @@ -568,7 +591,7 @@ describe("CLI error handling", () => {
});

test("plugin install replaces an existing Claude plugin and removes superseded caches", async () => {
const cwd = mkdtempSync(join(tmpdir(), "ctx-cli-plugin-update-"));
const cwd = mkdtempSync(join(executableTmpdir(), "ctx-cli-plugin-update-"));
const pluginRoot = join(cwd, "plugins");
const binDir = join(cwd, "bin");
const commandLog = join(cwd, "claude-commands.log");
Expand Down
17 changes: 17 additions & 0 deletions packages/context-cli/src/__tests__/statusReadCost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,23 @@ test("command reads share bytes but detect additions, replacement, deletion and
} finally { spy.mockRestore(); }
});

test("command reads see additions made within the same file system timestamp tick", async () => {
const parent = resolve(".tmp/approved-read-tests");
await fs.mkdir(parent, { recursive: true });
const root = await fs.mkdtemp(join(parent, "case-")); roots.push(root);
await fs.mkdir(join(root, "knowledge/architecture"), { recursive: true });
await fs.writeFile(join(root, "knowledge/architecture/a.md"), "first");
await withCommandReadCache(async () => {
// Coarse timestamps (ext4 advances by kernel tick) usually leave the
// directory stamp unchanged across these additions; each must still appear.
for (let index = 0; index < 50; index++) {
expect(await readApprovedMarkdownFiles(root)).toHaveLength(index + 1);
await fs.writeFile(join(root, `knowledge/architecture/added-${index}.md`), "added");
}
expect(await readApprovedMarkdownFiles(root)).toHaveLength(51);
});
});

test("new investigation keeps its actual route without re-auditing previously delivered prose", async () => {
const root = await initialRevisionKnowledge(roots);
await resumeWorkspaceTask(root);
Expand Down
20 changes: 17 additions & 3 deletions packages/context-cli/src/project/commandReadCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,25 @@ import { DerivedValueCache } from "./derivedValueCache.js";

interface CachedRead {
stamp: string;
/** The stamp was taken too soon after a change to prove later changes. */
racy: boolean;
value: Promise<unknown>;
}

const commandReads = new AsyncLocalStorage<Map<string, CachedRead>>();
const MAX_READS = 64;
/** File systems advance timestamps in coarse ticks (ext4 by kernel tick, some
* by whole seconds). Adding a directory entry in the same tick as an earlier
* change leaves dev/ino/size/mtime/ctime equal, so, like Git's racily-clean
* rule, a listing whose directory changed within this window is never reused.
* Files need no such guard: Context replaces them atomically (new inode). */
const RACY_WINDOW_NS = 1_000_000_000n;
const commandValues = new AsyncLocalStorage<DerivedValueCache>();

/** Cache one directory listing, never the entire subtree. Every traversal
* checks each child directory so nested additions/removals remain visible. */
export async function readCommandDirectory(path: string) {
const entries = await reuseCommandFileRead({ key: "directory-entries", paths: [path],
const entries = await reuseCommandFileRead({ key: "directory-entries", paths: [path], racyWindow: true,
read: () => readdir(path, { withFileTypes: true }) });
return [...entries];
}
Expand All @@ -32,23 +40,29 @@ export function withCommandReadCache<T>(action: () => Promise<T>): Promise<T> {
export async function reuseCommandFileRead<T>(input: {
key: string;
paths: readonly string[];
/** Never reuse a read whose paths changed within the timestamp window. */
racyWindow?: boolean;
read: () => Promise<T>;
}): Promise<T> {
const cache = commandReads.getStore();
if (cache === undefined) return input.read();
const paths = input.paths.map((path) => resolve(path));
const key = JSON.stringify([input.key, paths]);
let stamp: string;
let racy = false;
try {
const takenNs = BigInt(Date.now()) * 1_000_000n;
stamp = JSON.stringify(await Promise.all(paths.map(async (path) => {
const file = await stat(path, { bigint: true });
if (input.racyWindow && file.mtimeNs + RACY_WINDOW_NS > takenNs) racy = true;
return [file.dev, file.ino, file.size, file.mtimeNs, file.ctimeNs].map(String);
})));
} catch {
cache.delete(key);
return input.read();
}
const previous = cache.get(key);
const cached = cache.get(key);
const previous = cached?.racy ? undefined : cached;
const projectRoot = currentDebugProjectRoot();
if (projectRoot) await recordContextDebugPerformance({ projectRoot, operation: "command.read-cache", durationMs: 0,
outcome: "success", counters: { paths_checked: paths.length, cache_hits: previous?.stamp === stamp ? 1 : 0,
Expand All @@ -58,7 +72,7 @@ export async function reuseCommandFileRead<T>(input: {
const value = Promise.resolve().then(input.read);
cache.delete(key);
if (cache.size >= MAX_READS) cache.delete(cache.keys().next().value!);
const entry = { stamp, value };
const entry = { stamp, racy, value };
cache.set(key, entry);
try {
const resolved = await value;
Expand Down
Loading