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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## Unreleased

- Scope agent temporary files and default Go caches to owned invocation directories,
reclaim them after completion, and add `jury cache clean` for stopped runs (#122).
- Retain Windows agent caches when descendant termination cannot be confirmed,
including during startup cleanup and `jury cache clean`.
- Preserve cache ownership metadata until cache data is deleted, so interrupted
cleanup remains retryable.
- Record temporary-cache initialization failures as agent errors, allowing other
reviewers to finish and their reports and round status to be saved.
- Recognize quoted dispute examples in JSON replies with CRLF line endings,
avoiding false reopened findings and preserving the correct rebuttal evidence.
- Keep agreed findings settled in mixed replies with explicit dispute IDs, and
Expand Down
20 changes: 20 additions & 0 deletions bin/jury.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// deciding whether a finding reproduces is the calling agent's job, and roughly
// a third of suggestions do not survive that step.
import { commandHelp } from "../lib/help.js";
import { agentTempRoot, agentTemps } from "../lib/agent-temp.js";
import { parseArgs } from "node:util";
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
Expand Down Expand Up @@ -78,6 +79,7 @@ Other commands
jury agents judge <agent> set the global default judge
jury agents jury <agents> set the default reviewers
jury runs every PR under review
jury cache [clean] list or reclaim stopped agents' temporary caches
jury version

jury help --all every command and flag
Expand All @@ -89,6 +91,7 @@ const USAGE_FULL = `jury — review a pull request with multiple AI reviewers un
jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
jury reply [flags] send each reviewer your verdicts on ITS findings, one conversation each
jury runs list every PR under review, with its slug for --run
jury cache [clean] list or reclaim stopped agents' temporary caches
jury agents check which configured agents are installed
jury agents judge <agent> set the global default judge
jury agents jury <agents> set the default reviewers (picker in a terminal)
Expand Down Expand Up @@ -143,6 +146,11 @@ reply flags
state commands
jury runs [--dir <path>]
jury reply [--dir <path>] [--run <slug>]

cache commands
jury cache [list] list managed temporary directories and ownership status
jury cache clean remove only known, stopped invocations
Uses ~/.jury/tmp/agents, or JURY_AGENT_TEMP_ROOT. Active and unrecognized entries stay.
`;

let [, , cmd, ...rest] = process.argv;
Expand Down Expand Up @@ -181,6 +189,7 @@ try {
case "finding": case "findings": await cmdFinding(rest); break;
case "reply": await cmdReply(rest); break;
case "runs": await cmdRuns(rest); break;
case "cache": await cmdCache(rest); break;
case "agents": await cmdAgents(rest); break;
case "version": case "-v": case "--version": console.log(`jury ${VERSION}`); break;
case "help": case "-h": case "--help": case undefined:
Expand All @@ -207,6 +216,17 @@ try {
process.exit(1);
}

async function cmdCache(args) {
if (args.length > 1 || args.length && !["list", "clean"].includes(args[0])) {
throw new Error("Usage: jury cache [list|clean]");
}
const root = agentTempRoot();
const entries = await agentTemps({ root, clean: args[0] === "clean" });
console.log(`Agent temporary caches: ${root}`);
for (const entry of entries) console.log(`${entry.status}\t${entry.path}`);
if (!entries.length) console.log("No managed temporary directories.");
}

/** Resolve an explicit reviewer list or explain each ineligible name. */
function selectReviewers(cfg, requested, judge = null) {
if (!requested) return defaultReviewers(cfg, judge);
Expand Down
40 changes: 40 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,3 +241,43 @@ event leaves it unavailable. A forced kill can preserve only bytes already recei
Protocol references: [Codex events](https://github.com/openai/codex/blob/main/sdk/typescript/src/events.ts),
[Droid result JSON](https://docs.factory.com/droid-exec/overview#json),
[OpenCode token normalization](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/session.ts).


## Temporary files and Go caches

Each reviewer, judge and reply invocation gets an owned directory under
`~/.jury/tmp/agents`. Jury points `TMPDIR`, `TMP` and `TEMP` there and supplies
`GOCACHE` and `GOMODCACHE` defaults inside it. Tools using those variables or their
normal temporary-directory API stay within that invocation's storage. File-based
prompts use the same directory. Jury removes it after success, failure or timeout,
once the agent's process group has stopped. On Windows, a reviewer's exit does not
confirm that its descendants stopped, so launched invocations retain their caches.
Read-only Go module directories are also removed when cleanup is safe.

An explicit `GOCACHE` or `GOMODCACHE` in the parent environment or agent `env`
configuration takes precedence and remains your responsibility. Set these when
persistent Go caches are preferred over a fresh cache for each invocation.
`JURY_AGENT_TEMP_ROOT` selects another managed root; an agent-specific value is
supported, and the same value must be used when inspecting it from the CLI.
Jury overrides inherited temporary-directory variables for each invocation.
If temporary-cache initialization fails (for example, the root is unwritable),
Jury records an error for that agent and lets other reviewers finish. Their reports
and the failed round status are saved before the review stops.

Cleanup keeps ownership metadata until cache content is deleted, so interrupted
deletions can be retried. Interrupted invocations are reclaimed when the next
agent starts, or explicitly:

```sh
jury cache # list directories and ownership status
jury cache clean # reclaim known stopped invocations
```

Cleanup preserves live owners and process groups, unknown directories, symlinks,
and launches whose process identity could not be recorded. Windows invocations
remain `uncertain` after their owner exits, including after timeout termination;
startup cleanup and `jury cache clean` preserve them. An uncertain entry requires
manual inspection before removal. Processes that deliberately detach from their
agent's process group are outside this lifecycle. Explicit external caches and arbitrary
`/tmp` or `/private/tmp` directories are never claimed or removed. This does not
retroactively identify old, unmarked caches created before managed storage.
153 changes: 153 additions & 0 deletions lib/agent-temp.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
// Only directories created here, with a stopped owner and process group, may
// be reclaimed. Never infer ownership from a Go/cache-like name in /tmp.
import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, rmdir } from "node:fs/promises";
import { renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";

export function agentTempRoot(env = process.env) {
return path.resolve(env.JURY_AGENT_TEMP_ROOT || path.join(homedir(), ".jury", "tmp", "agents"));
}

function alive(pid) {
try { process.kill(pid, 0); return true; }
catch (error) { return error.code !== "ESRCH"; }
}

function childAlive(owner) {
return owner.childPid && alive(owner.processGroup ? -owner.childPid : owner.childPid);
}

function treeUncertain(owner) {
// Without a process group, an exited reviewer PID cannot establish that its
// descendants stopped. Keep the cache even after the owning Jury exits.
return owner.phase === "running" && !owner.processGroup;
}

function validOwner(owner) {
return owner?.version === 1 && owner.kind === "codejury-agent-temp"
&& Number.isSafeInteger(owner.pid) && owner.pid > 0
&& ["prepared", "launching", "running"].includes(owner.phase)
&& typeof owner.processGroup === "boolean"
&& (owner.phase === "running"
? Number.isSafeInteger(owner.childPid) && owner.childPid > 0
: owner.childPid === null);
}

async function checkRoot(root, create = false) {
if (create) await mkdir(root, { recursive: true, mode: 0o700 });
let stat;
try { stat = await lstat(root); }
catch (error) { if (error.code === "ENOENT" && !create) return false; throw error; }
if (!stat.isDirectory() || stat.isSymbolicLink()
|| process.getuid && stat.uid !== process.getuid()) {
throw new Error(`Agent temporary root must be an owned directory, not a symlink: ${root}`);
}
return true;
}

// Go module caches contain read-only directories. Make owned directories
// removable, without following symlinks or changing external cache permissions.
async function removeOwned(dir) {
async function writable(entry) {
let stat;
try { stat = await lstat(entry); }
catch (error) { if (error.code === "ENOENT") return; throw error; }
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
await chmod(entry, stat.mode | 0o700);
for (const name of await readdir(entry)) await writable(path.join(entry, name));
}
await writable(dir);
let entries;
try { entries = await readdir(dir); }
catch (error) { if (error.code === "ENOENT") return; throw error; }
// Keep the ownership marker until all cache data is gone, so an interrupted
// deletion can still be recognized and retried by startup or cache clean.
for (const name of entries) {
if (name !== "owner.json") await rm(path.join(dir, name), { recursive: true, force: true });
}
await rm(path.join(dir, "owner.json"), { force: true });
try { await rmdir(dir); }
catch (error) { if (error.code !== "ENOENT") throw error; }
}

/** Inventory or reclaim interrupted invocations; unknown and active entries stay. */
export async function agentTemps({ root = agentTempRoot(), clean = false } = {}) {
root = path.resolve(root);
if (!await checkRoot(root)) return [];
const entries = [];
for (const name of await readdir(root)) {
const dir = path.join(root, name);
let status = "unrecognized";
try {
const stat = await lstat(dir);
if (/^agent-/.test(name) && stat.isDirectory() && !stat.isSymbolicLink()
&& (!process.getuid || stat.uid === process.getuid())) {
const marker = path.join(dir, "owner.json");
const markerStat = await lstat(marker);
const owner = markerStat.isFile() && !markerStat.isSymbolicLink()
? JSON.parse(await readFile(marker, "utf8")) : null;
if (validOwner(owner)) {
status = alive(owner.pid) || childAlive(owner) ? "active"
: owner.phase === "launching" || treeUncertain(owner) ? "uncertain" : "stale";
if (clean && status === "stale") {
await removeOwned(dir);
status = "removed";
}
}
}
} catch (error) {
// A missing marker is an unknown entry; a missing directory was already
// reclaimed by another cleanup. Do not hide unmarked cache directories.
if (error.code === "ENOENT") {
if (!await lstat(dir).catch(() => null)) continue;
} else if (!(error instanceof SyntaxError)) status = `retained: ${error.message}`;
}
entries.push({ path: dir, status });
}
return entries;
}

export async function createAgentTemp(env = process.env) {
const root = agentTempRoot(env);
await checkRoot(root, true);
await agentTemps({ root, clean: true });
const dir = await mkdtemp(path.join(root, "agent-"));
const temporary = path.join(dir, "tmp");
const owner = { version: 1, kind: "codejury-agent-temp", pid: process.pid,
childPid: null, processGroup: process.platform !== "win32", phase: "prepared" };
const save = () => {
writeFileSync(path.join(dir, "owner.next"), JSON.stringify(owner), { mode: 0o600 });
renameSync(path.join(dir, "owner.next"), path.join(dir, "owner.json"));
};
try {
save();
await mkdir(temporary, { mode: 0o700 });
for (const name of ["go-build", "go-mod"]) await mkdir(path.join(temporary, name));
} catch (error) {
await removeOwned(dir);
throw error;
}
return {
directory: temporary,
env: { ...env, TMPDIR: temporary, TMP: temporary, TEMP: temporary,
GOCACHE: env.GOCACHE || path.join(temporary, "go-build"),
GOMODCACHE: env.GOMODCACHE || path.join(temporary, "go-mod") },
launching() { owner.phase = "launching"; save(); },
spawned(pid) {
owner.childPid = pid || null;
owner.phase = pid ? "running" : "prepared";
save();
},
async dispose() {
if (treeUncertain(owner)) return false;
// Group termination happens in agents.js. OS reaping may lag its result.
for (let n = 0; childAlive(owner) && n < 40; n++) {
await new Promise(resolve => setTimeout(resolve, 25));
}
if (childAlive(owner)) return false;
await removeOwned(dir);
return true;
},
};
}
46 changes: 37 additions & 9 deletions lib/agents.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Spawning reviewers and reading what they said back.
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { trackUsage, interruptUsage } from "./usage.js";
import { createAgentTemp } from "./agent-temp.js";

/**
* Where Code Jury itself is installed, for files that ship with it.
Expand Down Expand Up @@ -96,7 +96,34 @@ function watchGroup(pid) {
* dead reviewer is a result, not a crash, and the round should still report the
* others.
*/
export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onLog, onChunk, onTimeout, timeoutSeconds, sessionId = null, resumed = false, usageContext }) {
export async function runAgent(agent, options) {
if (options.dryRun) return runAgentWithTemp(agent, options, null);
const started = Date.now();
let temporary;
try { temporary = await createAgentTemp({ ...process.env, ...agent.env }); }
catch (error) {
return {
agent: agent.name,
ok: false,
seconds: +((Date.now() - started) / 1000).toFixed(1),
verdict: "error",
findings: [],
report: `Could not initialize temporary cache: ${error.message}`,
sessionId: options.sessionId ?? null,
raw: "",
};
}
try { return await runAgentWithTemp(agent, options, temporary); }
finally {
try {
if (!await temporary.dispose()) options.onLog?.(`Temporary cache retained because agent processes may still be active: ${temporary.directory}`);
} catch (error) {
options.onLog?.(`Could not remove temporary cache ${temporary.directory}: ${error.message}. Run jury cache clean after processes stop.`);
}
}
}

async function runAgentWithTemp(agent, { worktree, prompt, stopToken, dryRun, onLog, onChunk, onTimeout, timeoutSeconds, sessionId = null, resumed = false, usageContext }, temporary) {
const started = Date.now();

if (dryRun) {
Expand All @@ -113,10 +140,8 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
}

let promptFile = null;
let tmp = null;
if (agent.promptDelivery === "file") {
tmp = await mkdtemp(path.join(tmpdir(), "jury-"));
promptFile = path.join(tmp, "prompt.md");
promptFile = path.join(temporary.directory, "prompt.md");
await writeFile(promptFile, prompt, "utf8");
}

Expand Down Expand Up @@ -162,15 +187,20 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
// stdin must be closed, not an open pipe. A pipe that never delivers and
// never ends leaves an agent waiting on input forever: codex sat at 0% CPU
// for over an hour before this was fixed.
temporary.launching();
child = spawn(cmd, args, {
cwd, env: { ...process.env, PWD: cwd, ...agent.env, ...modelEnv(agent) },
cwd, env: { ...temporary.env, PWD: cwd, ...modelEnv(agent) },
stdio: ["ignore", "pipe", "pipe"], detached: processGroup,
});
} catch (err) {
resolve({ code: -1, stdout: "", stderr: String(err) });
return;
}
const unwatch = processGroup ? watchGroup(child.pid) : () => {};
// A failed metadata write leaves the launch marked uncertain for future
// cleanup. Keep tracking and terminating the child already spawned.
try { temporary.spawned(child.pid); }
catch (error) { onLog?.(`Could not record temporary cache owner: ${error.message}`); }
// Pipe reads can split a UTF-8 character. Decode each stream incrementally
// so the live callback, saved raw text, and final report retain its bytes.
child.stdout.setEncoding("utf8");
Expand Down Expand Up @@ -246,8 +276,6 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL
});


if (tmp) await rm(tmp, { recursive: true, force: true });

const seconds = +((Date.now() - started) / 1000).toFixed(1);

// A resumable agent that prints a session id gets it captured here. Without
Expand Down
1 change: 1 addition & 0 deletions lib/help.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading