feat: reuse executor sessions within Codex tasks
This commit is contained in:
@@ -77,10 +77,14 @@ agent-workflow decide --run <run-id> --input decision.json
|
|||||||
Start a new workflow run. Requires a clean working tree.
|
Start a new workflow run. Requires a clean working tree.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--vcs <git|svn|none>] [--max-cycles <n>]
|
agent-workflow start --plan <plan.json|-> --executor <reasonix|claude|agy> [--vcs <git|svn|none>] [--max-cycles <n>] [--new-task]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `--vcs` - Explicitly specify VCS kind (`git`, `svn`, or `none`). If omitted, auto-detects from the working directory. If neither Git nor SVN is detected, it automatically falls back to `none` (snapshot mode).
|
- `--vcs` - Explicitly specify VCS kind (`git`, `svn`, or `none`). If omitted, auto-detects from the working directory. If neither Git nor SVN is detected, it automatically falls back to `none` (snapshot mode).
|
||||||
|
- When `CODEX_THREAD_ID` is available, runs in the same Codex thread and repository share a current task. Each executor is created lazily on first use and its reliable session handle is reused by later plans in that task.
|
||||||
|
- `--new-task` creates a new task boundary and clears all three lazy executor bindings. Use it only when the conversation has moved to a materially different task.
|
||||||
|
- Claude, Reasonix, and Agy bindings remain separate. Selecting another executor does not switch an active run; it starts or resumes that executor only in a later run after the repository is clean.
|
||||||
|
- Agy sessions without a captured conversation ID are not shared because `--continue` cannot guarantee which conversation will be selected.
|
||||||
- Git and SVN working copies are automatically detected when `--vcs` is not provided.
|
- Git and SVN working copies are automatically detected when `--vcs` is not provided.
|
||||||
- SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts).
|
- SVN working copies must be in a clean, consistent state (no mixed revisions, switched paths, externals, or conflicts).
|
||||||
- None (snapshot) mode skips clean checks, capturing the directory state of the working directory and storing it in the run directory.
|
- None (snapshot) mode skips clean checks, capturing the directory state of the working directory and storing it in the run directory.
|
||||||
@@ -220,6 +224,9 @@ Create `agent-workflow.json` in your repository root:
|
|||||||
|
|
||||||
- `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`)
|
- `AGENT_WORKFLOW_DIR` — Override state root (default: `~/.agent-workflow/workflows`)
|
||||||
- `AGENT_WORKFLOW_META_STDOUT` — Emit metadata to stdout instead of stderr (set to `1`)
|
- `AGENT_WORKFLOW_META_STDOUT` — Emit metadata to stdout instead of stderr (set to `1`)
|
||||||
|
- `CODEX_THREAD_ID` — Supplied by Codex. Enables task-level executor session reuse for the current Codex thread and repository. Without it, each workflow run keeps the previous standalone behavior.
|
||||||
|
|
||||||
|
Task bindings are stored under the workflow state root, outside the target repository. Before reuse, the host checks the owning Codex thread, repository, task, executor kind, and persisted executor session. A missing or mismatched session blocks reuse instead of silently selecting a recent session. Start with `--new-task` only when a fresh task is intended.
|
||||||
|
|
||||||
The Agy adapter runs its non-interactive implementation sessions with
|
The Agy adapter runs its non-interactive implementation sessions with
|
||||||
`--dangerously-skip-permissions`. This is required because Agy cannot prompt for
|
`--dangerously-skip-permissions`. This is required because Agy cannot prompt for
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Support these explicit operations:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
$agent-workflow-host 执行已确认计划
|
$agent-workflow-host 执行已确认计划
|
||||||
|
$agent-workflow-host 执行新任务计划
|
||||||
$agent-workflow-host 使用 claude 执行已确认计划
|
$agent-workflow-host 使用 claude 执行已确认计划
|
||||||
$agent-workflow-host 使用 svn 执行已确认计划
|
$agent-workflow-host 使用 svn 执行已确认计划
|
||||||
$agent-workflow-host 继续 <run-id>
|
$agent-workflow-host 继续 <run-id>
|
||||||
@@ -30,6 +31,9 @@ $agent-workflow-host 日志 <run-id> [--follow]
|
|||||||
- If invoked without an operation, show the supported operations and do nothing.
|
- If invoked without an operation, show the supported operations and do nothing.
|
||||||
- Default to `reasonix`. Use `claude` or `agy` only when the user explicitly selects it or the approved plan already names it.
|
- Default to `reasonix`. Use `claude` or `agy` only when the user explicitly selects it or the approved plan already names it.
|
||||||
- Never switch or fall back to another executor during a run.
|
- Never switch or fall back to another executor during a run.
|
||||||
|
- Treat the current task as the durable binding identified by `CODEX_THREAD_ID` plus the target repository. Each task may lazily bind one Reasonix, one Claude, and one Agy session; do not eagerly start unused executors.
|
||||||
|
- A normal `执行` starts a new run in the current task and automatically reuses the selected executor's bound session when one exists. The other executor bindings remain untouched.
|
||||||
|
- Use `执行新任务计划` and pass `--new-task` only when the user clearly starts a materially different task. If the boundary is ambiguous, ask whether the prior executor context should be retained.
|
||||||
|
|
||||||
## Host Boundaries
|
## Host Boundaries
|
||||||
|
|
||||||
@@ -68,9 +72,11 @@ $agent-workflow-host 日志 <run-id> [--follow]
|
|||||||
7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently:
|
7. Start the run and record the emitted run ID, then immediately print a logs-follow command the user can run independently:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> [--vcs <git|svn|none>]
|
agent-workflow start --plan <temp-plan.json> --executor <reasonix|claude|agy> [--vcs <git|svn|none>] [--new-task]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Do not pass `--new-task` for a follow-up plan, a correction, or another implementation slice of the same user goal. The CLI validates persisted task bindings before reuse and blocks stale or mismatched handles. An executor selected for the first time in the task starts lazily and is then recorded for later runs. Agy degraded `--continue` handles are intentionally not shared.
|
||||||
|
|
||||||
After starting, announce:
|
After starting, announce:
|
||||||
```text
|
```text
|
||||||
Run <run-id> started. Monitor live output: agent-workflow logs --run <run-id> --follow
|
Run <run-id> started. Monitor live output: agent-workflow logs --run <run-id> --follow
|
||||||
@@ -164,6 +170,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
|
|||||||
## Failure Handling
|
## Failure Handling
|
||||||
|
|
||||||
- Missing session handle, executor failure, or baseline drift (Git HEAD change, SVN revision change) must stop as `blocked`; do not work around the gate. A missing Reasonix handle may use validated automatic recovery, and a missing Claude session may use the explicit validated session-rebind retry operation when a matching persisted session is known.
|
- Missing session handle, executor failure, or baseline drift (Git HEAD change, SVN revision change) must stop as `blocked`; do not work around the gate. A missing Reasonix handle may use validated automatic recovery, and a missing Claude session may use the explicit validated session-rebind retry operation when a matching persisted session is known.
|
||||||
|
- If task-level reuse is rejected because a persisted session is missing or belongs to another repository, do not fall back to a recent external session. Use `--new-task` only after confirming this is a new task; otherwise preserve the binding and report the recovery problem.
|
||||||
- When a resume failed before producing cycle artifacts because the same session was busy, ask the user to close the owning executor process, then use `retry-execute`; never kill an unrelated process automatically.
|
- When a resume failed before producing cycle artifacts because the same session was busy, ask the user to close the owning executor process, then use `retry-execute`; never kill an unrelated process automatically.
|
||||||
- Out-of-scope edits must enter `awaiting_scope_resolution`; inspect and remediate them through the same executor session. Repeated identical violations must stop as `needs_human`.
|
- Out-of-scope edits must enter `awaiting_scope_resolution`; inspect and remediate them through the same executor session. Repeated identical violations must stop as `needs_human`.
|
||||||
- Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence.
|
- Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence.
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ async function main() {
|
|||||||
executor: args.executor,
|
executor: args.executor,
|
||||||
vcs: args.vcs,
|
vcs: args.vcs,
|
||||||
maxCycles: args.maxCycles,
|
maxCycles: args.maxCycles,
|
||||||
|
newTask: args.newTask,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -91,9 +91,24 @@ export type {
|
|||||||
export {
|
export {
|
||||||
createExecutorAdapter,
|
createExecutorAdapter,
|
||||||
probeExecutor,
|
probeExecutor,
|
||||||
|
validateClaudeSessionForRepository,
|
||||||
validateClaudeSessionForWorkflow,
|
validateClaudeSessionForWorkflow,
|
||||||
|
validateReasonixSessionForRepository,
|
||||||
} from "./workflow-executor.js";
|
} 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 {
|
export type {
|
||||||
ExecutorAdapter,
|
ExecutorAdapter,
|
||||||
ExecutorResult,
|
ExecutorResult,
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseWorkflowArgs } from "./workflow-args.js";
|
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", () => {
|
describe("workflow retry-execute arguments", () => {
|
||||||
it("accepts an explicit Claude session ID", () => {
|
it("accepts an explicit Claude session ID", () => {
|
||||||
expect(parseWorkflowArgs([
|
expect(parseWorkflowArgs([
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface WorkflowStartArgs {
|
|||||||
vcs?: string;
|
vcs?: string;
|
||||||
planInput: string; // file path or "-"
|
planInput: string; // file path or "-"
|
||||||
maxCycles?: number;
|
maxCycles?: number;
|
||||||
|
newTask: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowReviewArgs {
|
export interface WorkflowReviewArgs {
|
||||||
@@ -94,7 +95,7 @@ function parsePositiveInt(flag: string, value: string): number {
|
|||||||
|
|
||||||
export const WORKFLOW_USAGE = `
|
export const WORKFLOW_USAGE = `
|
||||||
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 review --run <run-id>
|
||||||
agent-workflow retry-review --run <run-id>
|
agent-workflow retry-review --run <run-id>
|
||||||
agent-workflow retry-execute --run <run-id> [--session <claude-session-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 vcs: string | undefined;
|
||||||
let planInput: string | undefined;
|
let planInput: string | undefined;
|
||||||
let maxCycles: number | undefined;
|
let maxCycles: number | undefined;
|
||||||
|
let newTask = false;
|
||||||
|
|
||||||
while (rest.length > 0) {
|
while (rest.length > 0) {
|
||||||
const arg = rest.shift()!;
|
const arg = rest.shift()!;
|
||||||
@@ -158,6 +160,9 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
|||||||
case "--max-cycles":
|
case "--max-cycles":
|
||||||
maxCycles = parsePositiveInt(arg, requireValue(arg, rest));
|
maxCycles = parsePositiveInt(arg, requireValue(arg, rest));
|
||||||
break;
|
break;
|
||||||
|
case "--new-task":
|
||||||
|
newTask = true;
|
||||||
|
break;
|
||||||
case "-h":
|
case "-h":
|
||||||
case "--help":
|
case "--help":
|
||||||
workflowUsage(0);
|
workflowUsage(0);
|
||||||
@@ -169,7 +174,7 @@ export function parseWorkflowArgs(argv: string[]): WorkflowArgs {
|
|||||||
|
|
||||||
if (!planInput) throw new WorkflowArgsError("--plan is required for workflow start");
|
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": {
|
case "review": {
|
||||||
|
|||||||
+135
-4
@@ -27,8 +27,16 @@ import {
|
|||||||
createExecutorAdapter,
|
createExecutorAdapter,
|
||||||
findReasonixSessionForWorkflow,
|
findReasonixSessionForWorkflow,
|
||||||
probeExecutor,
|
probeExecutor,
|
||||||
|
validateClaudeSessionForRepository,
|
||||||
validateClaudeSessionForWorkflow,
|
validateClaudeSessionForWorkflow,
|
||||||
|
validateReasonixSessionForRepository,
|
||||||
} from "./workflow-executor.js";
|
} from "./workflow-executor.js";
|
||||||
|
import {
|
||||||
|
getOrCreateTaskBinding,
|
||||||
|
readTaskBinding,
|
||||||
|
saveTaskExecutorBinding,
|
||||||
|
WorkflowTaskBindingError,
|
||||||
|
} from "./workflow-task-binding.js";
|
||||||
import {
|
import {
|
||||||
WorkflowLockError,
|
WorkflowLockError,
|
||||||
acquireLock,
|
acquireLock,
|
||||||
@@ -56,7 +64,9 @@ import {
|
|||||||
} from "./vcs-provider.js";
|
} from "./vcs-provider.js";
|
||||||
import type {
|
import type {
|
||||||
CycleRecord,
|
CycleRecord,
|
||||||
|
ExecutorKind,
|
||||||
ExecutorResult,
|
ExecutorResult,
|
||||||
|
ExecutorSessionHandle,
|
||||||
HostDecisionV1,
|
HostDecisionV1,
|
||||||
HostReviewBundleV1,
|
HostReviewBundleV1,
|
||||||
ScopeViolation,
|
ScopeViolation,
|
||||||
@@ -259,8 +269,51 @@ export interface StartWorkflowOpts {
|
|||||||
executor?: string;
|
executor?: string;
|
||||||
vcs?: string;
|
vcs?: string;
|
||||||
maxCycles?: number;
|
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;
|
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 */
|
/** Resolve VCS selection from candidates and configs - exported for testing */
|
||||||
export async function resolveVcsSelection(
|
export async function resolveVcsSelection(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
@@ -425,6 +478,26 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
|||||||
});
|
});
|
||||||
|
|
||||||
const vcsProvider = createVcsProvider(vcsKind);
|
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
|
// 4. Probe executor
|
||||||
await probeExecutor(config.executor, config.executorConfig);
|
await probeExecutor(config.executor, config.executorConfig);
|
||||||
@@ -490,18 +563,46 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
|||||||
state.timeoutSeconds = config.timeoutSeconds;
|
state.timeoutSeconds = config.timeoutSeconds;
|
||||||
state.enginePid = process.pid;
|
state.enginePid = process.pid;
|
||||||
state.status = "executing";
|
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);
|
writeState(dir, state);
|
||||||
appendEvent(dir, {
|
appendEvent(dir, {
|
||||||
kind: "workflow_started",
|
kind: "workflow_started",
|
||||||
runId,
|
runId,
|
||||||
timestamp: state.createdAt,
|
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(`workflow started: ${runId}\n`);
|
||||||
process.stdout.write(` vcs: ${vcsKind}\n`);
|
process.stdout.write(` vcs: ${vcsKind}\n`);
|
||||||
process.stdout.write(` executor: ${config.executor}\n`);
|
process.stdout.write(` executor: ${config.executor}\n`);
|
||||||
process.stdout.write(` max-cycles: ${config.maxCycles}\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") {
|
if (vcsBaseline.kind === "git") {
|
||||||
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
process.stdout.write(` baseline: ${vcsBaseline.head}\n`);
|
||||||
} else if (vcsBaseline.kind === "svn") {
|
} else if (vcsBaseline.kind === "svn") {
|
||||||
@@ -521,7 +622,22 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
|||||||
writeState(dir, state);
|
writeState(dir, state);
|
||||||
|
|
||||||
appendEvent(dir, { kind: "cycle_started", runId, timestamp: nowIso(), cycleIndex });
|
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 adapter = createExecutorAdapter(config.executor);
|
||||||
const { ac, markActivity, cleanup } = createManagedController(state.timeoutSeconds);
|
const { ac, markActivity, cleanup } = createManagedController(state.timeoutSeconds);
|
||||||
@@ -534,7 +650,7 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
|||||||
|
|
||||||
let execResult;
|
let execResult;
|
||||||
try {
|
try {
|
||||||
execResult = await adapter.start({
|
const commonOpts = {
|
||||||
repoRoot,
|
repoRoot,
|
||||||
plan: typedPlan,
|
plan: typedPlan,
|
||||||
runDir: dir,
|
runDir: dir,
|
||||||
@@ -545,7 +661,15 @@ export async function startWorkflow(opts: StartWorkflowOpts): Promise<{ runId: s
|
|||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
onActivity: markActivity,
|
onActivity: markActivity,
|
||||||
vcsKind,
|
vcsKind,
|
||||||
});
|
};
|
||||||
|
execResult = taskSessionHandle
|
||||||
|
? await adapter.resume({
|
||||||
|
...commonOpts,
|
||||||
|
handle: taskSessionHandle,
|
||||||
|
acceptedFindings: "",
|
||||||
|
purpose: "task_plan",
|
||||||
|
})
|
||||||
|
: await adapter.start(commonOpts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cleanup();
|
cleanup();
|
||||||
transitionToTerminal(state, dir, "blocked", `Executor failed to start: ${(err as Error).message}`);
|
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);
|
persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath);
|
||||||
if (execResult.sessionHandle) {
|
if (execResult.sessionHandle) {
|
||||||
state.sessionHandle = execResult.sessionHandle;
|
state.sessionHandle = execResult.sessionHandle;
|
||||||
|
persistTaskSession(state, execResult.sessionHandle);
|
||||||
if (execResult.sessionHandle.kind === "agy") {
|
if (execResult.sessionHandle.kind === "agy") {
|
||||||
state.agyDegraded = execResult.sessionHandle.degraded;
|
state.agyDegraded = execResult.sessionHandle.degraded;
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1127,7 @@ async function resumeExecutorCycle(
|
|||||||
persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath);
|
persistExecutorAttempt(dir, cycleRecord, execResult, execLogPath);
|
||||||
if (execResult.sessionHandle) {
|
if (execResult.sessionHandle) {
|
||||||
state.sessionHandle = execResult.sessionHandle;
|
state.sessionHandle = execResult.sessionHandle;
|
||||||
|
persistTaskSession(state, execResult.sessionHandle);
|
||||||
if (execResult.sessionHandle.kind === "agy") {
|
if (execResult.sessionHandle.kind === "agy") {
|
||||||
state.agyDegraded = execResult.sessionHandle.degraded;
|
state.agyDegraded = execResult.sessionHandle.degraded;
|
||||||
}
|
}
|
||||||
@@ -1649,6 +1775,8 @@ export function statusWorkflow(opts: StatusWorkflowOpts): void {
|
|||||||
maxCycles: state.maxCycles,
|
maxCycles: state.maxCycles,
|
||||||
remainingCycles: Math.max(0, state.maxCycles - state.currentCycle),
|
remainingCycles: Math.max(0, state.maxCycles - state.currentCycle),
|
||||||
sessionHandleKind: state.sessionHandle?.kind,
|
sessionHandleKind: state.sessionHandle?.kind,
|
||||||
|
taskId: state.taskId,
|
||||||
|
taskSessionReused: state.taskSessionReused,
|
||||||
degradedResume: state.agyDegraded,
|
degradedResume: state.agyDegraded,
|
||||||
runDir: dir,
|
runDir: dir,
|
||||||
diffFile: cycle?.diffFile ? path.join(dir, cycle.diffFile) : undefined,
|
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]" : "";
|
const extra = kind === "agy" && state.agyDegraded ? " [degraded-continue]" : "";
|
||||||
lines.push(p("Session handle", kind + extra));
|
lines.push(p("Session handle", kind + extra));
|
||||||
}
|
}
|
||||||
|
if (state.taskId) {
|
||||||
|
lines.push(p("Task", `${state.taskId}${state.taskSessionReused ? " [session reused]" : ""}`));
|
||||||
|
}
|
||||||
|
|
||||||
// Live execution fields
|
// Live execution fields
|
||||||
if (output.executorPid) lines.push(p("Executor PID", String(output.executorPid)));
|
if (output.executorPid) lines.push(p("Executor PID", String(output.executorPid)));
|
||||||
|
|||||||
@@ -162,6 +162,31 @@ touch '${sessionFile}.meta'
|
|||||||
expect(result.sessionHandle?.kind).toBe("reasonix");
|
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 () => {
|
it("reports failure when binary exits with non-zero", async () => {
|
||||||
const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 });
|
const fakeBin = writeFakeCli(tmpDir, { exitCode: 1 });
|
||||||
|
|
||||||
|
|||||||
@@ -264,6 +264,36 @@ ${getSafetyConstraints(opts.vcsKind)}`;
|
|||||||
function buildFixPrompt(opts: ExecutorResumeOpts): string {
|
function buildFixPrompt(opts: ExecutorResumeOpts): string {
|
||||||
const { plan, acceptedFindings } = opts;
|
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")) {
|
if (acceptedFindings.startsWith("# Scope Remediation")) {
|
||||||
return `# Scope Fix Request: ${plan.title}
|
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);
|
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[] {
|
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||||
if (!fs.existsSync(dir)) return [];
|
if (!fs.existsSync(dir)) return [];
|
||||||
const result: string[] = [];
|
const result: string[] = [];
|
||||||
@@ -638,6 +684,22 @@ export function validateClaudeSessionForWorkflow(
|
|||||||
repoRoot: string,
|
repoRoot: string,
|
||||||
planTitle: string,
|
planTitle: string,
|
||||||
): 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)) {
|
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}`);
|
throw new Error(`Invalid Claude session ID: ${sessionId}`);
|
||||||
}
|
}
|
||||||
@@ -688,11 +750,7 @@ export function validateClaudeSessionForWorkflow(
|
|||||||
if (!sessionMatches || !repoMatches) {
|
if (!sessionMatches || !repoMatches) {
|
||||||
throw new Error(`Claude session ${sessionId} does not belong to repository ${repoRoot}`);
|
throw new Error(`Claude session ${sessionId} does not belong to repository ${repoRoot}`);
|
||||||
}
|
}
|
||||||
if (!content.includes(planTitle)) {
|
return { sessionFile, content };
|
||||||
throw new Error(`Claude session ${sessionId} does not contain frozen plan title: ${planTitle}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessionFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractClaudeFailureReason(output: string): string | undefined {
|
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;
|
currentCycle: number;
|
||||||
cycles: CycleRecord[];
|
cycles: CycleRecord[];
|
||||||
sessionHandle?: ExecutorSessionHandle;
|
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;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
/** True when --agy-degraded-continue is in effect. */
|
/** True when --agy-degraded-continue is in effect. */
|
||||||
@@ -337,6 +343,8 @@ export interface WorkflowStatusOutput {
|
|||||||
maxCycles: number;
|
maxCycles: number;
|
||||||
remainingCycles: number;
|
remainingCycles: number;
|
||||||
sessionHandleKind?: string;
|
sessionHandleKind?: string;
|
||||||
|
taskId?: string;
|
||||||
|
taskSessionReused?: boolean;
|
||||||
degradedResume?: boolean;
|
degradedResume?: boolean;
|
||||||
runDir: string;
|
runDir: string;
|
||||||
diffFile?: string;
|
diffFile?: string;
|
||||||
@@ -373,6 +381,7 @@ export type WorkflowEventKind =
|
|||||||
| "workflow_started"
|
| "workflow_started"
|
||||||
| "cycle_started"
|
| "cycle_started"
|
||||||
| "executor_started"
|
| "executor_started"
|
||||||
|
| "executor_session_reused"
|
||||||
| "executor_retried"
|
| "executor_retried"
|
||||||
| "executor_session_rebound"
|
| "executor_session_rebound"
|
||||||
| "executor_completed"
|
| "executor_completed"
|
||||||
@@ -445,6 +454,8 @@ export interface ExecutorResumeOpts {
|
|||||||
plan: WorkflowPlanV1;
|
plan: WorkflowPlanV1;
|
||||||
/** Only the accepted (actionable) findings sent to the executor. */
|
/** Only the accepted (actionable) findings sent to the executor. */
|
||||||
acceptedFindings: string;
|
acceptedFindings: string;
|
||||||
|
/** A new frozen plan in the same task, rather than a review fix. */
|
||||||
|
purpose?: "fix" | "task_plan";
|
||||||
handle: ExecutorSessionHandle;
|
handle: ExecutorSessionHandle;
|
||||||
runDir: string;
|
runDir: string;
|
||||||
cycleIndex: number;
|
cycleIndex: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user