353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { abortWorkflow, startWorkflow } from "./workflow-engine.js";
|
|
import { readState, runDir } from "./workflow-state.js";
|
|
import { readTaskBinding } from "./workflow-task-binding.js";
|
|
|
|
describe("task-level executor session reuse", () => {
|
|
let tmpDir: string;
|
|
let repoRoot: string;
|
|
let stateRoot: string;
|
|
let homeDir: string;
|
|
let argsFile: string;
|
|
let previousHome: string | undefined;
|
|
let previousWorkflowDir: string | undefined;
|
|
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee161f";
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-task-reuse-"));
|
|
repoRoot = path.join(tmpDir, "repo");
|
|
stateRoot = path.join(tmpDir, "state");
|
|
homeDir = path.join(tmpDir, "home");
|
|
argsFile = path.join(tmpDir, "reasonix-args.bin");
|
|
fs.mkdirSync(repoRoot);
|
|
fs.mkdirSync(homeDir);
|
|
|
|
previousHome = process.env.HOME;
|
|
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
|
|
process.env.HOME = homeDir;
|
|
process.env.AGENT_WORKFLOW_DIR = stateRoot;
|
|
|
|
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
|
|
|
|
const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-");
|
|
const sessionsDir = path.join(homeDir, ".reasonix", "projects", projectKey, "sessions");
|
|
const sessionFile = path.join(sessionsDir, "task-session.jsonl");
|
|
const fakeBin = path.join(repoRoot, "fake-reasonix.sh");
|
|
fs.writeFileSync(fakeBin, `#!/bin/bash
|
|
printf '%s\\0' "$@" >> '${argsFile}'
|
|
if [[ "$1" == "--version" ]]; then exit 0; fi
|
|
if [[ "$*" == *"--resume"* ]]; then echo 'resumed'; exit 0; fi
|
|
mkdir -p '${sessionsDir}'
|
|
printf '%s\\n' '{"role":"system","content":"Current workspace: \\"${repoRoot}\\""}' > '${sessionFile}'
|
|
touch '${sessionFile}.meta'
|
|
echo 'started'
|
|
`, { mode: 0o755 });
|
|
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
|
|
version: "1",
|
|
executors: { reasonix: { binary: fakeBin } },
|
|
}));
|
|
fs.writeFileSync(path.join(repoRoot, "tracked.txt"), "unchanged\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoRoot });
|
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot, stdio: "ignore" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
|
|
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writePlan(name: string): string {
|
|
const planPath = path.join(tmpDir, `${name}.json`);
|
|
fs.writeFileSync(planPath, JSON.stringify({
|
|
version: "1",
|
|
title: name,
|
|
planMarkdown: `Implement ${name}.`,
|
|
scope: ["tracked.txt"],
|
|
acceptanceCriteria: [`${name} is complete`],
|
|
verificationCommands: [["git", "status", "--short"]],
|
|
}));
|
|
return planPath;
|
|
}
|
|
|
|
it("resumes the same executor when a later plan belongs to the current Codex task", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("First plan"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const firstState = readState(runDir(repoRoot, first.runId))!;
|
|
const firstTaskId = firstState.taskId;
|
|
expect(firstState.taskSessionReused).toBe(false);
|
|
expect(readTaskBinding(repoRoot, threadId)?.executors.reasonix?.handle).toEqual(firstState.sessionHandle);
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
|
|
const second = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("Second plan"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const secondState = readState(runDir(repoRoot, second.runId))!;
|
|
expect(secondState.taskId).toBe(firstTaskId);
|
|
expect(secondState.taskSessionReused).toBe(true);
|
|
expect(secondState.sessionHandle).toEqual(firstState.sessionHandle);
|
|
|
|
const args = fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean);
|
|
expect(args).toContain("--resume");
|
|
expect(args).toContain((firstState.sessionHandle as { sessionFilePath: string }).sessionFilePath);
|
|
expect(args.some((arg) => arg.includes("# Continued Task Plan: Second plan"))).toBe(true);
|
|
const events = fs.readFileSync(path.join(runDir(repoRoot, second.runId), "events.jsonl"), "utf8");
|
|
expect(events).toContain('"kind":"executor_session_reused"');
|
|
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
|
|
});
|
|
|
|
it("starts a fresh task and session when newTask is explicit", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("First task"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const firstTaskId = readState(runDir(repoRoot, first.runId))?.taskId;
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
|
|
fs.rmSync(argsFile, { force: true });
|
|
const second = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("New task"),
|
|
codexThreadId: threadId,
|
|
newTask: true,
|
|
});
|
|
const secondState = readState(runDir(repoRoot, second.runId))!;
|
|
expect(secondState.taskId).not.toBe(firstTaskId);
|
|
expect(secondState.taskSessionReused).toBe(false);
|
|
expect(fs.readFileSync(argsFile, "utf8").split("\0").filter(Boolean)).not.toContain("--resume");
|
|
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
|
|
});
|
|
|
|
it("blocks a missing bound session instead of silently replacing it", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("Recoverable task"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const handle = readState(runDir(repoRoot, first.runId))?.sessionHandle;
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
expect(handle?.kind).toBe("reasonix");
|
|
if (handle?.kind === "reasonix") {
|
|
fs.rmSync(handle.sessionFilePath, { force: true });
|
|
fs.rmSync(`${handle.sessionFilePath}.meta`, { force: true });
|
|
}
|
|
|
|
await expect(startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("Follow-up plan"),
|
|
codexThreadId: threadId,
|
|
})).rejects.toThrow(/Cannot reuse the current task's reasonix session/);
|
|
|
|
const fresh = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "reasonix",
|
|
planInput: writePlan("Intentional new task"),
|
|
codexThreadId: threadId,
|
|
newTask: true,
|
|
});
|
|
expect(readState(runDir(repoRoot, fresh.runId))?.taskSessionReused).toBe(false);
|
|
abortWorkflow({ runId: fresh.runId, reason: "test cleanup" });
|
|
});
|
|
});
|
|
|
|
describe("Codex task-level executor session reuse", () => {
|
|
let tmpDir: string;
|
|
let repoRoot: string;
|
|
let stateRoot: string;
|
|
let homeDir: string;
|
|
let previousHome: string | undefined;
|
|
let previousWorkflowDir: string | undefined;
|
|
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee162f";
|
|
const sessionUuid = "b7a8c901-2345-4def-9012-3456789abcde";
|
|
let codexBin: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-codex-reuse-"));
|
|
repoRoot = path.join(tmpDir, "repo");
|
|
stateRoot = path.join(tmpDir, "state");
|
|
homeDir = path.join(tmpDir, "home");
|
|
codexBin = path.join(tmpDir, "fake-codex.sh");
|
|
fs.mkdirSync(repoRoot);
|
|
fs.mkdirSync(homeDir);
|
|
|
|
previousHome = process.env.HOME;
|
|
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
|
|
process.env.HOME = homeDir;
|
|
process.env.AGENT_WORKFLOW_DIR = stateRoot;
|
|
|
|
execFileSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoRoot });
|
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoRoot });
|
|
|
|
// Create a valid codex session file in ~/.codex/sessions/
|
|
const sessionsDir = path.join(homeDir, ".codex", "sessions");
|
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(sessionsDir, `${sessionUuid}.jsonl`),
|
|
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: repoRoot } }) + "\n" +
|
|
JSON.stringify({ type: "initialization", message: { content: "# Implementation Task: Test Codex Plan" } }) + "\n",
|
|
);
|
|
|
|
// Create a fake Codex CLI that records args and emits thread.started on start/resume
|
|
const uuid = sessionUuid; // captured for bash var expansion
|
|
fs.writeFileSync(codexBin, `#!/bin/bash
|
|
# Fake Codex CLI: record args to NUL-delimited file, emit thread.started
|
|
printf '%s\\0' "$@" >> '${path.join(tmpDir, "codex-args.bin")}'
|
|
if [[ "$1" == "--version" ]]; then
|
|
echo "fake-codex 0.1.0"
|
|
exit 0
|
|
fi
|
|
if [[ "$1" == "exec" && "$2" == "resume" ]]; then
|
|
# Session ID is immediately before the final "-" prompt marker
|
|
resumedId="\${@: -2:1}"
|
|
echo '{"type":"thread.started","thread_id":"'"$resumedId"'","thread":{"id":"'"$resumedId"'"}}'
|
|
echo '{"type":"turn.completed"}'
|
|
echo '{"type":"result","result":"Resume complete"}'
|
|
exit 0
|
|
elif [[ "$1" == "exec" ]]; then
|
|
echo "{\\"type\\":\\"thread.started\\",\\"thread\\":{\\"id\\":\\"${uuid}\\"}}"
|
|
echo '{"type":"item.started","item":{"name":"Bash"}}'
|
|
echo '{"type":"item.completed","item":{"name":"Bash"}}'
|
|
echo '{"type":"turn.completed"}'
|
|
exit 0
|
|
else
|
|
exit 1
|
|
fi
|
|
`, { mode: 0o755 });
|
|
|
|
fs.writeFileSync(path.join(repoRoot, "agent-workflow.json"), JSON.stringify({
|
|
version: "1",
|
|
executors: { codex: { binary: codexBin } },
|
|
}));
|
|
fs.writeFileSync(path.join(repoRoot, "tracked.txt"), "unchanged\n");
|
|
execFileSync("git", ["add", "."], { cwd: repoRoot });
|
|
execFileSync("git", ["commit", "-m", "init"], { cwd: repoRoot, stdio: "ignore" });
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (previousHome === undefined) delete process.env.HOME;
|
|
else process.env.HOME = previousHome;
|
|
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
|
|
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function writePlan(name: string): string {
|
|
const planPath = path.join(tmpDir, `${name}.json`);
|
|
fs.writeFileSync(planPath, JSON.stringify({
|
|
version: "1",
|
|
title: name,
|
|
planMarkdown: `Implement ${name}.`,
|
|
scope: ["tracked.txt"],
|
|
acceptanceCriteria: [`${name} is complete`],
|
|
verificationCommands: [["git", "status", "--short"]],
|
|
}));
|
|
return planPath;
|
|
}
|
|
|
|
it("resumes the same Codex session when a later plan belongs to the current task", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex First Plan"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const firstState = readState(runDir(repoRoot, first.runId))!;
|
|
const firstTaskId = firstState.taskId;
|
|
expect(firstState.taskSessionReused).toBe(false);
|
|
expect(firstState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
|
|
expect(readTaskBinding(repoRoot, threadId)?.executors.codex?.handle).toEqual(firstState.sessionHandle);
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
|
|
const second = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex Second Plan"),
|
|
codexThreadId: threadId,
|
|
});
|
|
const secondState = readState(runDir(repoRoot, second.runId))!;
|
|
expect(secondState.taskId).toBe(firstTaskId);
|
|
expect(secondState.taskSessionReused).toBe(true);
|
|
// The resumed session must use the EXACT same ID
|
|
expect(secondState.sessionHandle).toEqual({ kind: "codex", sessionId: sessionUuid });
|
|
|
|
const args = fs.readFileSync(path.join(tmpDir, "codex-args.bin"), "utf8").split("\0").filter(Boolean);
|
|
expect(args).toContain("resume");
|
|
expect(args).toContain(sessionUuid);
|
|
const events = fs.readFileSync(path.join(runDir(repoRoot, second.runId), "events.jsonl"), "utf8");
|
|
expect(events).toContain('"kind":"executor_session_reused"');
|
|
abortWorkflow({ runId: second.runId, reason: "test cleanup" });
|
|
});
|
|
|
|
it("blocks reuse when Codex session file is missing from ~/.codex/sessions", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex Recoverable Task"),
|
|
codexThreadId: threadId,
|
|
});
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
|
|
// Remove the session file so validation fails
|
|
const sessionsDir = path.join(homeDir, ".codex", "sessions");
|
|
fs.rmSync(path.join(sessionsDir, `${sessionUuid}.jsonl`), { force: true });
|
|
|
|
await expect(startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex Follow-up Plan"),
|
|
codexThreadId: threadId,
|
|
})).rejects.toThrow(/Codex session file not found/);
|
|
});
|
|
|
|
it("blocks reuse when Codex session belongs to a different repository", async () => {
|
|
const first = await startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex Recoverable Task 2"),
|
|
codexThreadId: threadId,
|
|
});
|
|
abortWorkflow({ runId: first.runId, reason: "test boundary" });
|
|
|
|
// Rewrite the session file with a different cwd
|
|
const sessionsDir = path.join(homeDir, ".codex", "sessions");
|
|
fs.writeFileSync(
|
|
path.join(sessionsDir, `${sessionUuid}.jsonl`),
|
|
JSON.stringify({ type: "session_meta", payload: { id: sessionUuid, cwd: "/different/repo" } }) + "\n",
|
|
);
|
|
|
|
await expect(startWorkflow({
|
|
cwd: repoRoot,
|
|
executor: "codex",
|
|
reviewer: "claude",
|
|
planInput: writePlan("Codex Wrong Repo Plan"),
|
|
codexThreadId: threadId,
|
|
})).rejects.toThrow(/does not belong to repository/);
|
|
});
|
|
});
|