fix: recover missing Reasonix sessions
This commit is contained in:
@@ -125,6 +125,8 @@ agent-workflow retry-execute --run <run-id> --session <claude-session-id>
|
||||
|
||||
The override is accepted only for Claude runs. The session JSONL must exist under `~/.claude/projects`, belong to the run repository, and contain the frozen plan title. Baseline, scope, and review-artifact gates still apply, and the old/new session IDs are appended to the workflow audit log.
|
||||
|
||||
For Reasonix runs blocked after a session handle was not captured, `retry-execute` automatically recovers the persisted session only when its project directory, frozen plan title, and execution time window match the run. The recovery is recorded in the workflow audit log.
|
||||
|
||||
### `agent-workflow status`
|
||||
|
||||
Display current workflow status.
|
||||
|
||||
@@ -154,7 +154,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
|
||||
- For `查看 <run-id>`, run `agent-workflow status --run <run-id> --json`, summarize it, and do not advance the workflow.
|
||||
- For `继续 <run-id>`, read status first, then continue from `awaiting_review`, `awaiting_scope_resolution`, or `awaiting_host`. Read the persisted state and referenced cycle artifacts when earlier command output is no longer in context; do not reconstruct findings from memory.
|
||||
- For `清理后重试 <run-id>`, run `agent-workflow retry-review --run <run-id>` after the approved scope artifacts have been removed. It must refuse when baseline drift or out-of-scope paths remain.
|
||||
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
|
||||
- For `执行器重试 <run-id>`, run `agent-workflow retry-execute --run <run-id>` after the external cause of an executor failure is cleared (e.g., session conflict resolved, transient error gone). It preserves failed attempt logs and reuses the same session handle. If a Reasonix handle was not captured, the CLI may recover a persisted session only when its project directory, frozen plan title, and execution time window match the run, and must audit the recovery. Initial-cycle retries continue from the original plan; later-cycle retries resume from the preceding decision. Refuses when baseline has drifted, scope violations exist, or the cycle already produced review artifacts.
|
||||
- For `执行器重试 <run-id> 使用会话 <claude-session-id>`, run `agent-workflow retry-execute --run <run-id> --session <claude-session-id>` only when a Claude run's recorded session was never created or is unavailable and a known persisted replacement session contains the same frozen plan. The CLI must validate that the session belongs to the same repository and plan before recording the rebind in the audit log and retrying. Never edit `state.json` manually or guess a session ID.
|
||||
- For `延长 <run-id> 添加 <n> 个周期`, run `agent-workflow extend --run <run-id> --additional-cycles <n>` to extend a `budget_exhausted` run. This requires: the run ended at `budget_exhausted`, has a valid session handle, the last decision was `fix`, baseline hasn't drifted, and scope is clean. Returns to `awaiting_host` state to continue the same executor session.
|
||||
- Treat `completed`, `needs_human`, `blocked`, and `aborted` as terminal. Report them instead of attempting another cycle.
|
||||
@@ -163,7 +163,7 @@ After `fix`, repeat review and decision for the new cycle. Respect the configure
|
||||
|
||||
## 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 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.
|
||||
- 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`.
|
||||
- Same finding in consecutive reviews or disappear/reappear oscillation must stop as `needs_human` when the engine reports non-convergence.
|
||||
|
||||
+29
-4
@@ -23,7 +23,12 @@ import { execFileSync } from "node:child_process";
|
||||
import { resolveWorkflowConfig, validateHostDecision, validateWorkflowPlan, WorkflowConfigError } from "./workflow-config.js";
|
||||
import { checkConvergence, decisionSignatures } from "./workflow-convergence.js";
|
||||
import type { FindingSignature } from "./workflow-convergence.js";
|
||||
import { createExecutorAdapter, probeExecutor, validateClaudeSessionForWorkflow } from "./workflow-executor.js";
|
||||
import {
|
||||
createExecutorAdapter,
|
||||
findReasonixSessionForWorkflow,
|
||||
probeExecutor,
|
||||
validateClaudeSessionForWorkflow,
|
||||
} from "./workflow-executor.js";
|
||||
import {
|
||||
WorkflowLockError,
|
||||
acquireLock,
|
||||
@@ -1269,7 +1274,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
const retryableFailure = staleExecution || (state.status === "blocked"
|
||||
&& (state.stopDescription?.startsWith("Executor exited with failure:") === true
|
||||
|| state.stopDescription?.startsWith("Executor failed to resume:") === true
|
||||
|| state.stopDescription?.startsWith("Executor resume failed:") === true));
|
||||
|| state.stopDescription?.startsWith("Executor resume failed:") === true
|
||||
|| state.stopDescription === "No session handle available for resume."));
|
||||
if (!retryableFailure) {
|
||||
throw new WorkflowEngineError(
|
||||
`Run '${opts.runId}' cannot retry execution from status '${state.status}'. Only failed executor runs with a resumable session are eligible.`,
|
||||
@@ -1282,7 +1288,15 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
if (!cycle || cycle.diffFile || cycle.hostReviewFile || cycle.decisionFile || cycle.scopeViolations) {
|
||||
throw new WorkflowEngineError("Cannot retry execution: the failed cycle already contains review artifacts.", 4);
|
||||
}
|
||||
if (!state.sessionHandle && !opts.sessionId) {
|
||||
const recoveredReasonixSession = !state.sessionHandle && state.executor === "reasonix"
|
||||
? findReasonixSessionForWorkflow(
|
||||
state.repoRoot,
|
||||
state.plan.title,
|
||||
previousCycle?.startedAt ?? cycle.startedAt,
|
||||
previousCycle?.completedAt,
|
||||
)
|
||||
: undefined;
|
||||
if (!state.sessionHandle && !opts.sessionId && !recoveredReasonixSession) {
|
||||
throw new WorkflowEngineError("Cannot retry execution: no executor session handle is available.", 4);
|
||||
}
|
||||
const recoveringInitialCycle = state.currentCycle === 1;
|
||||
@@ -1365,6 +1379,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
: undefined;
|
||||
if (opts.sessionId) {
|
||||
state.sessionHandle = { kind: "claude", sessionId: opts.sessionId };
|
||||
} else if (recoveredReasonixSession) {
|
||||
state.sessionHandle = { kind: "reasonix", sessionFilePath: recoveredReasonixSession };
|
||||
}
|
||||
|
||||
state.status = "executing";
|
||||
@@ -1382,6 +1398,14 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
cycleIndex: state.currentCycle,
|
||||
data: { previousSessionId, sessionId: opts.sessionId },
|
||||
});
|
||||
} else if (recoveredReasonixSession) {
|
||||
appendEvent(dir, {
|
||||
kind: "executor_session_rebound",
|
||||
runId: state.runId,
|
||||
timestamp: state.updatedAt,
|
||||
cycleIndex: state.currentCycle,
|
||||
data: { sessionFilePath: recoveredReasonixSession, recovery: "reasonix_project_session" },
|
||||
});
|
||||
}
|
||||
|
||||
// Determine recovery mode: stale_execution trumps initial_continuation
|
||||
@@ -1405,7 +1429,8 @@ export async function retryExecuteWorkflow(opts: RetryExecuteWorkflowOpts): Prom
|
||||
},
|
||||
});
|
||||
|
||||
process.stdout.write(`retrying executor cycle ${state.currentCycle} with the ${opts.sessionId ? "rebound" : "existing"} ${state.executor} session\n`);
|
||||
const sessionMode = opts.sessionId || recoveredReasonixSession ? "recovered" : "existing";
|
||||
process.stdout.write(`retrying executor cycle ${state.currentCycle} with the ${sessionMode} ${state.executor} session\n`);
|
||||
await resumeExecutorCycle(state, dir, cycle, acceptedFindings);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createExecutorAdapter } from "./workflow-executor.js";
|
||||
import { createExecutorAdapter, findReasonixSessionForWorkflow } from "./workflow-executor.js";
|
||||
import type { WorkflowPlanV1 } from "./workflow-types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
@@ -103,6 +103,41 @@ describe("ReasonixAdapter", () => {
|
||||
expect(result.failed).toBe(false);
|
||||
});
|
||||
|
||||
it("captures a session from the Reasonix project directory when JSONL workspace is a parent", async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const fakeHome = path.join(tmpDir, "home");
|
||||
process.env.HOME = fakeHome;
|
||||
try {
|
||||
const projectKey = path.resolve(tmpDir).replaceAll(path.sep, "-");
|
||||
const sessionsDir = path.join(fakeHome, ".reasonix", "projects", projectKey, "sessions");
|
||||
const sessionFile = path.join(sessionsDir, "test-session.jsonl");
|
||||
const fakeBin = path.join(tmpDir, "fake-reasonix.sh");
|
||||
fs.writeFileSync(fakeBin, `#!/bin/bash
|
||||
mkdir -p '${sessionsDir}'
|
||||
printf '%s\\n' '{"role":"system","content":"Current workspace: \\"${path.dirname(tmpDir)}\\""}' > '${sessionFile}'
|
||||
printf '%s\\n' '{"role":"user","content":"# Implementation Task: Test plan"}' >> '${sessionFile}'
|
||||
touch '${sessionFile}.meta'
|
||||
`, { mode: 0o755 });
|
||||
|
||||
const adapter = createExecutorAdapter("reasonix");
|
||||
const startedAt = new Date(Date.now() - 1000).toISOString();
|
||||
const result = await adapter.start({
|
||||
repoRoot: tmpDir,
|
||||
plan: makeValidPlan(),
|
||||
runDir: tmpDir,
|
||||
cycleIndex: 1,
|
||||
config: { binary: fakeBin },
|
||||
});
|
||||
|
||||
expect(result.sessionHandle).toEqual({ kind: "reasonix", sessionFilePath: sessionFile });
|
||||
expect(findReasonixSessionForWorkflow(tmpDir, "Test plan", startedAt)).toBe(sessionFile);
|
||||
expect(findReasonixSessionForWorkflow(tmpDir, "Different plan", startedAt)).toBeUndefined();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("resume builds args with --resume", async () => {
|
||||
const argsFile = path.join(tmpDir, "args.txt");
|
||||
const fakeBin = writeFakeCli(tmpDir, { argsFile });
|
||||
|
||||
@@ -384,6 +384,17 @@ function getWorkspaceFromJsonl(jsonlPath: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reasonixProjectDir(projectsDir: string, repoRoot: string): string {
|
||||
const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-");
|
||||
return path.join(projectsDir, projectKey);
|
||||
}
|
||||
|
||||
function isSessionInReasonixProject(jsonlPath: string, projectsDir: string, repoRoot: string): boolean {
|
||||
const sessionsDir = path.join(reasonixProjectDir(projectsDir, repoRoot), "sessions");
|
||||
const relative = path.relative(sessionsDir, jsonlPath);
|
||||
return relative !== "" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const result: string[] = [];
|
||||
@@ -411,7 +422,7 @@ function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||
if (fs.existsSync(metaFile)) {
|
||||
if (repoRoot) {
|
||||
const ws = getWorkspaceFromJsonl(full);
|
||||
if (ws === repoRoot) {
|
||||
if (ws === repoRoot || isSessionInReasonixProject(full, dir, repoRoot)) {
|
||||
result.push(full);
|
||||
}
|
||||
} else {
|
||||
@@ -427,6 +438,41 @@ function listJsonlFiles(dir: string, repoRoot?: string): string[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
function sessionContainsPlanTitle(jsonlPath: string, planTitle: string): boolean {
|
||||
try {
|
||||
const content = fs.readFileSync(jsonlPath, "utf8");
|
||||
return content.includes(`# Implementation Task: ${planTitle}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function findReasonixSessionForWorkflow(
|
||||
repoRoot: string,
|
||||
planTitle: string,
|
||||
startedAt?: string,
|
||||
completedAt?: string,
|
||||
): string | undefined {
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
const projectsDir = path.join(homeDir, ".reasonix", "projects");
|
||||
const startMs = startedAt ? Date.parse(startedAt) : Number.NEGATIVE_INFINITY;
|
||||
const endMs = completedAt ? Date.parse(completedAt) : Number.POSITIVE_INFINITY;
|
||||
const toleranceMs = 5000;
|
||||
|
||||
const candidates = listJsonlFiles(projectsDir, repoRoot).filter((file) => {
|
||||
if (!sessionContainsPlanTitle(file, planTitle)) return false;
|
||||
try {
|
||||
const mtimeMs = fs.statSync(file).mtimeMs;
|
||||
return mtimeMs >= startMs - toleranceMs && mtimeMs <= endMs + toleranceMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
candidates.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function detectNewSessionFile(beforeSnapshot: Set<string>, searchDirs: string[], repoRoot: string): string | undefined {
|
||||
const candidates: string[] = [];
|
||||
for (const dir of searchDirs) {
|
||||
|
||||
@@ -267,6 +267,69 @@ echo '{"session_id":"fake-session-123","conversation_id":"fake-conv-456"}'
|
||||
.toBe("initial_continuation");
|
||||
});
|
||||
|
||||
it("recovers a missing Reasonix handle for a blocked fix cycle", async () => {
|
||||
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
|
||||
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
|
||||
const runId = "reasonix-missing-handle";
|
||||
const dir = runDir(repoRoot, runId);
|
||||
const projectKey = path.resolve(repoRoot).replaceAll(path.sep, "-");
|
||||
const sessionsDir = path.join(process.env.HOME!, ".reasonix", "projects", projectKey, "sessions");
|
||||
const sessionFile = path.join(sessionsDir, "matching-session.jsonl");
|
||||
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||
fs.writeFileSync(sessionFile, [
|
||||
JSON.stringify({ role: "system", content: `Current workspace: "${path.dirname(repoRoot)}"` }),
|
||||
JSON.stringify({ role: "user", content: "# Implementation Task: Test" }),
|
||||
].join("\n"));
|
||||
fs.writeFileSync(`${sessionFile}.meta`, "{}");
|
||||
|
||||
const fakeReasonix = path.join(tmpDir, "fake-reasonix-resume");
|
||||
fs.writeFileSync(fakeReasonix, "#!/bin/bash\nexit 0\n", { mode: 0o755 });
|
||||
const startedAt = new Date(Date.now() - 5000).toISOString();
|
||||
const state = initWorkflowState({
|
||||
runId,
|
||||
repoRoot,
|
||||
baselineHead: gitHead(repoRoot),
|
||||
plan: plan(),
|
||||
executor: "reasonix",
|
||||
maxCycles: 3,
|
||||
});
|
||||
state.status = "blocked";
|
||||
state.stopDescription = "No session handle available for resume.";
|
||||
state.currentCycle = 2;
|
||||
state.cycles = [
|
||||
{
|
||||
cycleIndex: 1,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
decisionFile: "cycle-1-decision.json",
|
||||
decisionOutcome: "fix",
|
||||
},
|
||||
{ cycleIndex: 2, startedAt: new Date().toISOString() },
|
||||
];
|
||||
state.executorConfig = { binary: fakeReasonix };
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "cycle-1-decision.json"), JSON.stringify({
|
||||
version: "1",
|
||||
outcome: "fix",
|
||||
findingDecisions: [],
|
||||
verificationEvidence: [],
|
||||
decidedAt: new Date().toISOString(),
|
||||
}));
|
||||
writeState(dir, state);
|
||||
|
||||
await retryExecuteWorkflow({ runId });
|
||||
|
||||
const recovered = readState(dir)!;
|
||||
expect(recovered.status).toBe("awaiting_review");
|
||||
expect(recovered.sessionHandle).toEqual({ kind: "reasonix", sessionFilePath: sessionFile });
|
||||
const events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8")
|
||||
.trim().split("\n").map((line) => JSON.parse(line));
|
||||
expect(events.find((event) => event.kind === "executor_session_rebound")?.data).toEqual({
|
||||
sessionFilePath: sessionFile,
|
||||
recovery: "reasonix_project_session",
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds a validated Claude session before retrying the failed cycle", async () => {
|
||||
const { retryExecuteWorkflow } = await import("./workflow-engine.js");
|
||||
const { gitHead, initWorkflowState, readState, writeState } = await import("./workflow-state.js");
|
||||
|
||||
Reference in New Issue
Block a user