feat: reuse executor sessions within Codex tasks

This commit is contained in:
liujing
2026-07-19 21:51:31 +08:00
parent 717d4344ee
commit 71b8025666
13 changed files with 702 additions and 13 deletions
+1
View File
@@ -45,6 +45,7 @@ async function main() {
executor: args.executor,
vcs: args.vcs,
maxCycles: args.maxCycles,
newTask: args.newTask,
});
break;
+15
View File
@@ -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,
+18
View File
@@ -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([
+7 -2
View File
@@ -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
View File
@@ -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)));
+25
View File
@@ -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 });
+63 -5
View File
@@ -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 {
+110
View File
@@ -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();
});
});
+130
View File
@@ -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;
}
+171
View File
@@ -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" });
});
});
+11
View File
@@ -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;