fix: recover missing Reasonix sessions
This commit is contained in:
+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