fix: recover missing Reasonix sessions

This commit is contained in:
liujing
2026-07-17 17:02:49 +08:00
parent fb39ab289a
commit 717d4344ee
6 changed files with 179 additions and 8 deletions
+29 -4
View File
@@ -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);
}
+36 -1
View File
@@ -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 });
+47 -1
View File
@@ -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) {
+63
View File
@@ -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");