feat: reuse executor sessions within Codex tasks
This commit is contained in:
@@ -45,6 +45,7 @@ async function main() {
|
||||
executor: args.executor,
|
||||
vcs: args.vcs,
|
||||
maxCycles: args.maxCycles,
|
||||
newTask: args.newTask,
|
||||
});
|
||||
break;
|
||||
|
||||
|
||||
@@ -91,9 +91,24 @@ export type {
|
||||
export {
|
||||
createExecutorAdapter,
|
||||
probeExecutor,
|
||||
validateClaudeSessionForRepository,
|
||||
validateClaudeSessionForWorkflow,
|
||||
validateReasonixSessionForRepository,
|
||||
} from "./workflow-executor.js";
|
||||
|
||||
export {
|
||||
getOrCreateTaskBinding,
|
||||
readTaskBinding,
|
||||
saveTaskExecutorBinding,
|
||||
taskBindingFile,
|
||||
WorkflowTaskBindingError,
|
||||
} from "./workflow-task-binding.js";
|
||||
|
||||
export type {
|
||||
TaskExecutorBinding,
|
||||
WorkflowTaskBindingV1,
|
||||
} from "./workflow-task-binding.js";
|
||||
|
||||
export type {
|
||||
ExecutorAdapter,
|
||||
ExecutorResult,
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseWorkflowArgs } from "./workflow-args.js";
|
||||
|
||||
describe("workflow start arguments", () => {
|
||||
it("accepts an explicit new task boundary", () => {
|
||||
expect(parseWorkflowArgs([
|
||||
"start",
|
||||
"--plan", "plan.json",
|
||||
"--executor", "reasonix",
|
||||
"--new-task",
|
||||
])).toEqual({
|
||||
sub: "start",
|
||||
planInput: "plan.json",
|
||||
executor: "reasonix",
|
||||
vcs: undefined,
|
||||
maxCycles: undefined,
|
||||
newTask: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow retry-execute arguments", () => {
|
||||
it("accepts an explicit Claude session ID", () => {
|
||||
expect(parseWorkflowArgs([
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface WorkflowStartArgs {
|
||||
vcs?: string;
|
||||
planInput: string; // file path or "-"
|
||||
maxCycles?: number;
|
||||
newTask: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowReviewArgs {
|
||||
@@ -94,7 +95,7 @@ function parsePositiveInt(flag: string, value: string): number {
|
||||
|
||||
export const WORKFLOW_USAGE = `
|
||||
Usage:
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn|none>] [--max-cycles <n>]
|
||||
agent-workflow start --executor <reasonix|claude|agy> --plan <plan.json|-> [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
|
||||
agent-workflow review --run <run-id>
|
||||
agent-workflow retry-review --run <run-id>
|
||||
agent-workflow retry-execute --run <run-id> [--session <claude-session-id>]
|
||||
@@ -142,6 +143,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
let vcs: string | undefined;
|
||||
let planInput: string | undefined;
|
||||
let maxCycles: number | undefined;
|
||||
let newTask = false;
|
||||
|
||||
while (rest.length > 0) {
|
||||
const arg = rest.shift()!;
|
||||
@@ -158,6 +160,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
case "--max-cycles":
|
||||
maxCycles = parsePositiveInt(arg, requireValue(arg, rest));
|
||||
break;
|
||||
case "--new-task":
|
||||
newTask = true;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
workflowUsage(0);
|
||||
@@ -169,7 +174,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
||||
|
||||
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
|
||||
|
||||
return { sub: "start", executor, vcs, planInput, maxCycles };
|
||||
return { sub: "start", executor, vcs, planInput, maxCycles, newTask };
|
||||
}
|
||||
|
||||
case "review": {
|
||||
|
||||
+135
-4
@@ -27,8 +27,16 @@ import {
|
||||
createExecutorAdapter,
|
||||
findReasonixSessionForWorkflow,
|
||||
probeExecutor,
|
||||
validateClaudeSessionForRepository,
|
||||
validateClaudeSessionForWorkflow,
|
||||
validateReasonixSessionForRepository,
|
||||
} from "./workflow-executor.js";
|
||||
import {
|
||||
getOrCreateTaskBinding,
|
||||
readTaskBinding,
|
||||
saveTaskExecutorBinding,
|
||||
WorkflowTaskBindingError,
|
||||
} from "./workflow-task-binding.js";
|
||||
import {
|
||||
WorkflowLockError,
|
||||
acquireLock,
|
||||
@@ -56,7 +64,9 @@ import {
|
||||
} from "./vcs-provider.js";
|
||||
import type {
|
||||
CycleRecord,
|
||||
ExecutorKind,
|
||||
ExecutorResult,
|
||||
ExecutorSessionHandle,
|
||||
HostDecisionV1,
|
||||
HostReviewBundleV1,
|
||||
ScopeViolation,
|
||||
@@ -259,8 +269,51 @@ export interface StartWorkflowOpts {
|
||||
executor?: string;
|
||||
vcs?: string;
|
||||
maxCycles?: number;
|
||||
/** Start a fresh task binding for this Codex thread and repository. */
|
||||
newTask?: boolean;
|
||||
/** Overrides CODEX_THREAD_ID for programmatic callers and tests. */
|
||||
codexThreadId?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function validateTaskSessionHandle(
|
||||
executor: ExecutorKind,
|
||||
handle: ExecutorSessionHandle,
|
||||
repoRoot: string,
|
||||
): void {
|
||||
if (handle.kind !== executor) {
|
||||
throw new WorkflowTaskBindingError(`Task binding contains a ${handle.kind} handle for executor ${executor}.`);
|
||||
}
|
||||
switch (handle.kind) {
|
||||
case "reasonix":
|
||||
validateReasonixSessionForRepository(handle.sessionFilePath, repoRoot);
|
||||
return;
|
||||
case "claude":
|
||||
validateClaudeSessionForRepository(handle.sessionId, repoRoot);
|
||||
return;
|
||||
case "agy":
|
||||
if (handle.degraded || !handle.conversationId || !/^[0-9a-f-]{36}$/i.test(handle.conversationId)) {
|
||||
throw new WorkflowTaskBindingError("Task binding contains an Agy session without a reliable conversation ID.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistTaskSession(state: WorkflowState, handle: ExecutorSessionHandle): void {
|
||||
if (!state.taskId || !state.codexThreadId) return;
|
||||
try {
|
||||
saveTaskExecutorBinding({
|
||||
taskId: state.taskId,
|
||||
codexThreadId: state.codexThreadId,
|
||||
repoRoot: state.repoRoot,
|
||||
executor: state.executor,
|
||||
handle,
|
||||
runId: state.runId,
|
||||
planTitle: state.plan.title,
|
||||
});
|
||||
} catch (err) {
|
||||
process.stderr.write(`Warning: failed to update task executor binding: ${(err as Error).message}\n`);
|
||||
}
|
||||
}
|
||||
/** Resolve VCS selection from candidates and configs - exported for testing */
|
||||
export async function resolveVcsSelection(
|
||||
cwd: string,
|
||||
@@ -425,6 +478,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
});
|
||||
|
||||
const vcsProvider = createVcsProvider(vcsKind);
|
||||
const codexThreadId = (opts.codexThreadId ?? process.env.CODEX_THREAD_ID)?.trim();
|
||||
if (opts.newTask && !codexThreadId) {
|
||||
throw new WorkflowEngineError("--new-task requires CODEX_THREAD_ID to identify the owning Codex session.", 4);
|
||||
}
|
||||
|
||||
let existingTaskBinding;
|
||||
let taskSessionHandle: ExecutorSessionHandle | undefined;
|
||||
if (codexThreadId && !opts.newTask) {
|
||||
try {
|
||||
existingTaskBinding = readTaskBinding(repoRoot, codexThreadId);
|
||||
taskSessionHandle = existingTaskBinding?.executors[config.executor]?.handle;
|
||||
if (taskSessionHandle) validateTaskSessionHandle(config.executor, taskSessionHandle, repoRoot);
|
||||
} catch (err) {
|
||||
throw new WorkflowEngineError(
|
||||
`Cannot reuse the current task's ${config.executor} session: ${(err as Error).message} ` +
|
||||
"Start with --new-task only when this is intentionally a new task.",
|
||||
4,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Probe executor
|
||||
await probeExecutor(config.executor, config.executorConfig);
|
||||
@@ -490,18 +563,46 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
state.timeoutSeconds = config.timeoutSeconds;
|
||||
state.enginePid = process.pid;
|
||||
state.status = "executing";
|
||||
if (codexThreadId) {
|
||||
try {
|
||||
const binding = opts.newTask || !existingTaskBinding
|
||||
? getOrCreateTaskBinding({
|
||||
repoRoot,
|
||||
codexThreadId,
|
||||
planTitle: typedPlan.title,
|
||||
newTask: opts.newTask,
|
||||
})
|
||||
: existingTaskBinding;
|
||||
state.taskId = binding.taskId;
|
||||
state.codexThreadId = codexThreadId;
|
||||
state.taskSessionReused = !!taskSessionHandle;
|
||||
if (taskSessionHandle) state.sessionHandle = taskSessionHandle;
|
||||
} catch (err) {
|
||||
releaseLock(repoRoot, runId);
|
||||
throw new WorkflowEngineError(`Cannot initialize Codex task binding: ${(err as Error).message}`, 4);
|
||||
}
|
||||
}
|
||||
writeState(dir, state);
|
||||
appendEvent(dir, {
|
||||
kind: "workflow_started",
|
||||
runId,
|
||||
timestamp: state.createdAt,
|
||||
data: { executor: config.executor, maxCycles: config.maxCycles, vcsKind, vcsBaseline },
|
||||
data: {
|
||||
executor: config.executor,
|
||||
maxCycles: config.maxCycles,
|
||||
vcsKind,
|
||||
vcsBaseline,
|
||||
...(state.taskId ? { taskId: state.taskId, taskSessionReused: state.taskSessionReused } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
process.stdout.write(`workflow started: ${runId}\n`);
|
||||
process.stdout.write(` vcs: ${vcsKind}\n`);
|
||||
process.stdout.write(` executor: ${config.executor}\n`);
|
||||
process.stdout.write(` max-cycles: ${config.maxCycles}\n`);
|
||||
if (state.taskId) {
|
||||
process.stdout.write(` task: ${state.taskId}${state.taskSessionReused ? " (session reused)" : ""}\n`);
|
||||
}
|
||||
if (vcsBaseline.kind === "git") {
|
||||
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
||||
} else if (vcsBaseline.kind === "svn") {
|
||||
@@ -521,7 +622,22 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
writeState(dir, state);
|
||||
|
||||
appendEvent(dir, { kind: "cycle_started", runId, timestamp: nowIso(), cycleIndex });
|
||||
appendEvent(dir, { kind: "executor_started", runId, timestamp: nowIso(), cycleIndex, data: { executor: config.executor } });
|
||||
appendEvent(dir, {
|
||||
kind: "executor_started",
|
||||
runId,
|
||||
timestamp: nowIso(),
|
||||
cycleIndex,
|
||||
data: { executor: config.executor, sessionMode: taskSessionHandle ? "task_reuse" : "new" },
|
||||
});
|
||||
if (taskSessionHandle) {
|
||||
appendEvent(dir, {
|
||||
kind: "executor_session_reused",
|
||||
runId,
|
||||
timestamp: nowIso(),
|
||||
cycleIndex,
|
||||
data: { executor: config.executor, taskId: state.taskId },
|
||||
});
|
||||
}
|
||||
|
||||
const adapter = createExecutorAdapter(config.executor);
|
||||
const { ac, markActivity, cleanup } = createManagedController(state.timeoutSeconds);
|
||||
@@ -534,7 +650,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
|
||||
let execResult;
|
||||
try {
|
||||
execResult = await adapter.start({
|
||||
const commonOpts = {
|
||||
repoRoot,
|
||||
plan: typedPlan,
|
||||
runDir: dir,
|
||||
@@ -545,7 +661,15 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
signal: ac.signal,
|
||||
onActivity: markActivity,
|
||||
vcsKind,
|
||||
});
|
||||
};
|
||||
execResult = taskSessionHandle
|
||||
? await adapter.resume({
|
||||
...commonOpts,
|
||||
handle: taskSessionHandle,
|
||||
acceptedFindings: "",
|
||||
purpose: "task_plan",
|
||||
})
|
||||
: await adapter.start(commonOpts);
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
transitionToTerminal(state, dir, "blocked", `Executor failed to start: ${(err as Error).message}`);
|
||||
@@ -556,6 +680,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
||||
persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath);
|
||||
if (execResult.sessionHandle) {
|
||||
state.sessionHandle = execResult.sessionHandle;
|
||||
persistTaskSession(state, execResult.sessionHandle);
|
||||
if (execResult.sessionHandle.kind === "agy") {
|
||||
state.agyDegraded = execResult.sessionHandle.degraded;
|
||||
}
|
||||
@@ -1002,6 +1127,7 @@ async function resumeExecutorCycle(
|
||||
persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath);
|
||||
if (execResult.sessionHandle) {
|
||||
state.sessionHandle = execResult.sessionHandle;
|
||||
persistTaskSession(state, execResult.sessionHandle);
|
||||
if (execResult.sessionHandle.kind === "agy") {
|
||||
state.agyDegraded = execResult.sessionHandle.degraded;
|
||||
}
|
||||
@@ -1649,6 +1775,8 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
maxCycles: state.maxCycles,
|
||||
remainingCycles: Math.max(0, state.maxCycles - state.currentCycle),
|
||||
sessionHandleKind: state.sessionHandle?.kind,
|
||||
taskId: state.taskId,
|
||||
taskSessionReused: state.taskSessionReused,
|
||||
degradedResume: state.agyDegraded,
|
||||
runDir: dir,
|
||||
diffFile: cycle?.diffFile ? path.join(dir, cycle.diffFile) : undefined,
|
||||
@@ -1693,6 +1821,9 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
||||
const extra = kind === "agy" && state.agyDegraded ? " [degraded-continue]" : "";
|
||||
lines.push(p("Session handle", kind + extra));
|
||||
}
|
||||
if (state.taskId) {
|
||||
lines.push(p("Task", `${state.taskId}${state.taskSessionReused ? " [session reused]" : ""}`));
|
||||
}
|
||||
|
||||
// Live execution fields
|
||||
if (output.executorPid) lines.push(p("Executor PID", String(output.executorPid)));
|
||||
|
||||
@@ -162,6 +162,31 @@ touch '${sessionFile}.meta'
|
||||
expect(result.sessionHandle?.kind).toBe("reasonix");
|
||||
});
|
||||
|
||||
it("resume sends a complete continued plan for task-level reuse", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
const sessionFile = path.join(tmpDir, "session.jsonl");
|
||||
fs.writeFileSync(sessionFile, "{}\n");
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
await adapter.resume({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
acceptedFindings: "",
|
||||
purpose: "task_plan",
|
||||
handle: { kind: "reasonix", sessionFilePath: sessionFile },
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
const capturedArgs = fs.readFileSync(argsFile, "utf8");
|
||||
expect(capturedArgs).toContain("# Continued Task Plan: Test plan");
|
||||
expect(capturedArgs).toContain("Do something.");
|
||||
expect(capturedArgs).toContain("Tests pass");
|
||||
expect(capturedArgs).not.toContain("# Fix Request");
|
||||
});
|
||||
|
||||
it("reports failure when binary exits with non-zero", async () => {
|
||||
const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 });
|
||||
|
||||
|
||||
@@ -264,6 +264,36 @@ ${getSafetyConstraints(opts.vcsKind)}`;
|
||||
function buildFixPrompt(opts: ExecutorResumeOpts): string {
|
||||
const { plan, acceptedFindings } = opts;
|
||||
|
||||
if (opts.purpose === "task_plan") {
|
||||
const scopeList = plan.scope.map((s) => ` - ${s}`).join("\n");
|
||||
const criteriaList = plan.acceptanceCriteria.map((c, i) => ` ${i + 1}. ${c}`).join("\n");
|
||||
const cmdsText = plan.verificationCommands.map((cmd) => ` ${cmd.join(" ")}`).join("\n");
|
||||
|
||||
return `# Continued Task Plan: ${plan.title}
|
||||
|
||||
This is a new frozen plan for the same task. Use the repository's current state and your existing task context.
|
||||
|
||||
## Plan
|
||||
|
||||
${plan.planMarkdown}
|
||||
|
||||
## Allowed Scope
|
||||
|
||||
Only modify files within these paths (relative to repository root):
|
||||
${scopeList}
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
${criteriaList}
|
||||
|
||||
## Verification Commands
|
||||
|
||||
After implementation, ensure these commands pass:
|
||||
${cmdsText}
|
||||
|
||||
${getSafetyConstraints(opts.vcsKind)}`;
|
||||
}
|
||||
|
||||
if (acceptedFindings.startsWith("# Scope Remediation")) {
|
||||
return `# Scope Fix Request: ${plan.title}
|
||||
|
||||
@@ -395,6 +425,22 @@ function isSessionInReasonixProject(jsonlPath: string, projectsDir: string, repo
|
||||
return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
export function validateReasonixSessionForRepository(sessionFilePath: string, repoRoot: string): string {
|
||||
const projectsDir = path.join(process.env.HOME || os.homedir(), ".reasonix", "projects");
|
||||
const resolved = path.resolve(sessionFilePath);
|
||||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile() || !resolved.endsWith(".jsonl")) {
|
||||
throw new Error(`Reasonix session file not found: ${resolved}`);
|
||||
}
|
||||
if (!fs.existsSync(`${resolved}.meta`)) {
|
||||
throw new Error(`Reasonix session metadata file not found: ${resolved}.meta`);
|
||||
}
|
||||
const workspaceMatches = getWorkspaceFromJsonl(resolved) === repoRoot;
|
||||
if (!workspaceMatches && !isSessionInReasonixProject(resolved, projectsDir, repoRoot)) {
|
||||
throw new Error(`Reasonix session does not belong to repository ${repoRoot}: ${resolved}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result: string[] = [];
|
||||
@@ -638,6 +684,22 @@ export function validateClaudeSessionForWorkflow(
|
||||
repoRoot: string,
|
||||
planTitle: string,
|
||||
): string {
|
||||
const { sessionFile, content } = readClaudeSessionForRepository(sessionId, repoRoot);
|
||||
if (!content.includes(planTitle)) {
|
||||
throw new Error(`Claude session ${sessionId} does not contain frozen plan title: ${planTitle}`);
|
||||
}
|
||||
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
export function validateClaudeSessionForRepository(sessionId: string, repoRoot: string): string {
|
||||
return readClaudeSessionForRepository(sessionId, repoRoot).sessionFile;
|
||||
}
|
||||
|
||||
function readClaudeSessionForRepository(
|
||||
sessionId: string,
|
||||
repoRoot: string,
|
||||
): { sessionFile: string; content: string } {
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
|
||||
throw new Error(`Invalid Claude session ID: ${sessionId}`);
|
||||
}
|
||||
@@ -688,11 +750,7 @@ export function validateClaudeSessionForWorkflow(
|
||||
if (!sessionMatches || !repoMatches) {
|
||||
throw new Error(`Claude session ${sessionId} does not belong to repository ${repoRoot}`);
|
||||
}
|
||||
if (!content.includes(planTitle)) {
|
||||
throw new Error(`Claude session ${sessionId} does not contain frozen plan title: ${planTitle}`);
|
||||
}
|
||||
|
||||
return sessionFile;
|
||||
return { sessionFile, content };
|
||||
}
|
||||
|
||||
function extractClaudeFailureReason(output: string): string | undefined {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getOrCreateTaskBinding,
|
||||
readTaskBinding,
|
||||
saveTaskExecutorBinding,
|
||||
taskBindingFile,
|
||||
} from "./workflow-task-binding.js";
|
||||
|
||||
describe("Codex task executor bindings", () => {
|
||||
let tmpDir: string;
|
||||
let repoRoot: string;
|
||||
let previousWorkflowDir: string | undefined;
|
||||
const threadId = "019f6e2d-333c-7900-bbf4-8ad8a9ee161f";
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-task-binding-"));
|
||||
repoRoot = path.join(tmpDir, "repo");
|
||||
fs.mkdirSync(repoRoot);
|
||||
previousWorkflowDir = process.env.AGENT_WORKFLOW_DIR;
|
||||
process.env.AGENT_WORKFLOW_DIR = path.join(tmpDir, "state");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousWorkflowDir === undefined) delete process.env.AGENT_WORKFLOW_DIR;
|
||||
else process.env.AGENT_WORKFLOW_DIR = previousWorkflowDir;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps one lazy session per executor in the current task", () => {
|
||||
const task = getOrCreateTaskBinding({
|
||||
repoRoot,
|
||||
codexThreadId: threadId,
|
||||
planTitle: "Initial plan",
|
||||
});
|
||||
|
||||
expect(saveTaskExecutorBinding({
|
||||
taskId: task.taskId,
|
||||
codexThreadId: threadId,
|
||||
repoRoot,
|
||||
executor: "claude",
|
||||
handle: { kind: "claude", sessionId: "22778b47-e4f3-4645-84cd-1c6a74a912d8" },
|
||||
runId: "run-1",
|
||||
planTitle: "Initial plan",
|
||||
})).toBe(true);
|
||||
expect(saveTaskExecutorBinding({
|
||||
taskId: task.taskId,
|
||||
codexThreadId: threadId,
|
||||
repoRoot,
|
||||
executor: "agy",
|
||||
handle: { kind: "agy", conversationId: "b3e8ec2d-8179-4f29-bcab-18c9387f56a1", degraded: false },
|
||||
runId: "run-2",
|
||||
planTitle: "Follow-up plan",
|
||||
})).toBe(true);
|
||||
|
||||
const restored = readTaskBinding(repoRoot, threadId)!;
|
||||
expect(restored.taskId).toBe(task.taskId);
|
||||
expect(restored.executors.claude?.lastRunId).toBe("run-1");
|
||||
expect(restored.executors.agy?.lastRunId).toBe("run-2");
|
||||
expect(restored.latestPlanTitle).toBe("Follow-up plan");
|
||||
expect(fs.statSync(taskBindingFile(repoRoot, threadId)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it("starts a new task without allowing an old run to overwrite it", () => {
|
||||
const oldTask = getOrCreateTaskBinding({
|
||||
repoRoot,
|
||||
codexThreadId: threadId,
|
||||
planTitle: "Old task",
|
||||
});
|
||||
const newTask = getOrCreateTaskBinding({
|
||||
repoRoot,
|
||||
codexThreadId: threadId,
|
||||
planTitle: "New task",
|
||||
newTask: true,
|
||||
});
|
||||
|
||||
expect(newTask.taskId).not.toBe(oldTask.taskId);
|
||||
expect(newTask.executors).toEqual({});
|
||||
expect(saveTaskExecutorBinding({
|
||||
taskId: oldTask.taskId,
|
||||
codexThreadId: threadId,
|
||||
repoRoot,
|
||||
executor: "reasonix",
|
||||
handle: { kind: "reasonix", sessionFilePath: path.join(tmpDir, "old.jsonl") },
|
||||
runId: "old-run",
|
||||
planTitle: "Old task",
|
||||
})).toBe(false);
|
||||
expect(readTaskBinding(repoRoot, threadId)?.taskId).toBe(newTask.taskId);
|
||||
});
|
||||
|
||||
it("does not pool an Agy degraded continuation", () => {
|
||||
const task = getOrCreateTaskBinding({
|
||||
repoRoot,
|
||||
codexThreadId: threadId,
|
||||
planTitle: "Task",
|
||||
});
|
||||
expect(saveTaskExecutorBinding({
|
||||
taskId: task.taskId,
|
||||
codexThreadId: threadId,
|
||||
repoRoot,
|
||||
executor: "agy",
|
||||
handle: { kind: "agy", degraded: true },
|
||||
runId: "run-1",
|
||||
planTitle: "Task",
|
||||
})).toBe(false);
|
||||
expect(readTaskBinding(repoRoot, threadId)?.executors.agy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { repoHash, workflowsRoot } from "./workflow-state.js";
|
||||
import type { ExecutorKind, ExecutorSessionHandle } from "./workflow-types.js";
|
||||
|
||||
export interface TaskExecutorBinding {
|
||||
handle: ExecutorSessionHandle;
|
||||
lastRunId: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkflowTaskBindingV1 {
|
||||
version: "1";
|
||||
taskId: string;
|
||||
codexThreadId: string;
|
||||
repoRoot: string;
|
||||
initialPlanTitle: string;
|
||||
latestPlanTitle: string;
|
||||
executors: Partial<Record<ExecutorKind, TaskExecutorBinding>>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class WorkflowTaskBindingError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "WorkflowTaskBindingError";
|
||||
}
|
||||
}
|
||||
|
||||
function threadHash(codexThreadId: string): string {
|
||||
return crypto.createHash("sha256").update(codexThreadId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function taskBindingFile(repoRoot: string, codexThreadId: string): string {
|
||||
return path.join(workflowsRoot(), "_task-bindings", threadHash(codexThreadId), `${repoHash(repoRoot)}.json`);
|
||||
}
|
||||
|
||||
function validateBinding(raw: unknown, filePath: string): WorkflowTaskBindingV1 {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new WorkflowTaskBindingError(`Task binding is not a JSON object: ${filePath}`);
|
||||
}
|
||||
const binding = raw as WorkflowTaskBindingV1;
|
||||
if (binding.version !== "1" || !binding.taskId || !binding.codexThreadId || !binding.repoRoot) {
|
||||
throw new WorkflowTaskBindingError(`Task binding is invalid: ${filePath}`);
|
||||
}
|
||||
if (!binding.executors || typeof binding.executors !== "object") {
|
||||
throw new WorkflowTaskBindingError(`Task binding has invalid executor sessions: ${filePath}`);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function readTaskBinding(repoRoot: string, codexThreadId: string): WorkflowTaskBindingV1 | undefined {
|
||||
const filePath = taskBindingFile(repoRoot, codexThreadId);
|
||||
if (!fs.existsSync(filePath)) return undefined;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
} catch (err) {
|
||||
throw new WorkflowTaskBindingError(`Failed to read task binding ${filePath}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const binding = validateBinding(raw, filePath);
|
||||
if (binding.codexThreadId !== codexThreadId || path.resolve(binding.repoRoot) !== path.resolve(repoRoot)) {
|
||||
throw new WorkflowTaskBindingError(`Task binding does not belong to this Codex thread and repository: ${filePath}`);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
function writeTaskBinding(binding: WorkflowTaskBindingV1): void {
|
||||
const filePath = taskBindingFile(binding.repoRoot, binding.codexThreadId);
|
||||
const tmp = `${filePath}.tmp.${process.pid}`;
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(tmp, JSON.stringify(binding, null, 2), { encoding: "utf8", mode: 0o600 });
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
export function getOrCreateTaskBinding(opts: {
|
||||
repoRoot: string;
|
||||
codexThreadId: string;
|
||||
planTitle: string;
|
||||
newTask?: boolean;
|
||||
}): WorkflowTaskBindingV1 {
|
||||
const existing = readTaskBinding(opts.repoRoot, opts.codexThreadId);
|
||||
if (existing && !opts.newTask) return existing;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const binding: WorkflowTaskBindingV1 = {
|
||||
version: "1",
|
||||
taskId: crypto.randomUUID(),
|
||||
codexThreadId: opts.codexThreadId,
|
||||
repoRoot: path.resolve(opts.repoRoot),
|
||||
initialPlanTitle: opts.planTitle,
|
||||
latestPlanTitle: opts.planTitle,
|
||||
executors: {},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
writeTaskBinding(binding);
|
||||
return binding;
|
||||
}
|
||||
|
||||
export function saveTaskExecutorBinding(opts: {
|
||||
taskId: string;
|
||||
codexThreadId: string;
|
||||
repoRoot: string;
|
||||
executor: ExecutorKind;
|
||||
handle: ExecutorSessionHandle;
|
||||
runId: string;
|
||||
planTitle: string;
|
||||
}): boolean {
|
||||
if (opts.handle.kind !== opts.executor) return false;
|
||||
if (opts.handle.kind === "agy" && (!opts.handle.conversationId || opts.handle.degraded)) return false;
|
||||
|
||||
const binding = readTaskBinding(opts.repoRoot, opts.codexThreadId);
|
||||
if (!binding || binding.taskId !== opts.taskId) return false;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
binding.executors[opts.executor] = {
|
||||
handle: opts.handle,
|
||||
lastRunId: opts.runId,
|
||||
updatedAt: now,
|
||||
};
|
||||
binding.latestPlanTitle = opts.planTitle;
|
||||
binding.updatedAt = now;
|
||||
writeTaskBinding(binding);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -219,6 +219,12 @@ export interface WorkflowState {
|
||||
currentCycle: number;
|
||||
cycles: CycleRecord[];
|
||||
sessionHandle?: ExecutorSessionHandle;
|
||||
/** Current task shared by this Codex thread and repository. */
|
||||
taskId?: string;
|
||||
/** Codex thread that owns taskId. */
|
||||
codexThreadId?: string;
|
||||
/** True when the first cycle resumed a task-level executor session. */
|
||||
taskSessionReused?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** True when --agy-degraded-continue is in effect. */
|
||||
@@ -337,6 +343,8 @@ export interface WorkflowStatusOutput {
|
||||
maxCycles: number;
|
||||
remainingCycles: number;
|
||||
sessionHandleKind?: string;
|
||||
taskId?: string;
|
||||
taskSessionReused?: boolean;
|
||||
degradedResume?: boolean;
|
||||
runDir: string;
|
||||
diffFile?: string;
|
||||
@@ -373,6 +381,7 @@ export type WorkflowEventKind =
|
||||
| "workflow_started"
|
||||
| "cycle_started"
|
||||
| "executor_started"
|
||||
| "executor_session_reused"
|
||||
| "executor_retried"
|
||||
| "executor_session_rebound"
|
||||
| "executor_completed"
|
||||
@@ -445,6 +454,8 @@ export interface ExecutorResumeOpts {
|
||||
plan: WorkflowPlanV1;
|
||||
/** Only the accepted (actionable) findings sent to the executor. */
|
||||
acceptedFindings: string;
|
||||
/** A new frozen plan in the same task, rather than a review fix. */
|
||||
purpose?: "fix" | "task_plan";
|
||||
handle: ExecutorSessionHandle;
|
||||
runDir: string;
|
||||
cycleIndex: number;
|
||||
|
||||
Reference in New Issue
Block a user