fix(core): cover single-session recovery failures

This commit is contained in:
Dhruv Sharma 2026-05-10 17:09:32 +05:30
parent 4618fc4c6b
commit f254117f6e
3 changed files with 82 additions and 21 deletions

View File

@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { runRecovery } from "../recovery/manager.js";
import { recoverSessionById, runRecovery } from "../recovery/manager.js";
import { recordActivityEvent } from "../activity-events.js";
import { getProjectDir, getProjectSessionsDir } from "../paths.js";
import {
@ -188,6 +188,55 @@ describe("runRecovery activity events", () => {
);
});
it("emits recovery.session_failed when single-session recovery fails", async () => {
vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) =>
makeAssessment(scanned.sessionId),
);
const failedResult: RecoveryResult = {
success: false,
sessionId: "app-2",
action: "recover",
error: "agent process missing",
};
vi.spyOn(actionsModule, "executeAction").mockResolvedValueOnce(failedResult);
const config = makeConfig(rootDir);
const registry = makeRegistry();
const result = await recoverSessionById("app-2", {
config,
registry,
recoveryConfig: {
...DEFAULT_RECOVERY_CONFIG,
logPath: join(rootDir, "recovery.log"),
},
});
expect(result).toEqual(failedResult);
const emitCalls = vi
.mocked(recordActivityEvent)
.mock.calls.map((c) => c[0])
.filter((e) => e.kind === "recovery.session_failed");
expect(emitCalls).toHaveLength(1);
expect(emitCalls[0]).toEqual(
expect.objectContaining({
sessionId: "app-2",
projectId: PROJECT_ID,
source: "recovery",
kind: "recovery.session_failed",
level: "error",
data: expect.objectContaining({
action: "recover",
errorMessage: "agent process missing",
}),
}),
);
});
it("does not emit recovery.session_failed when every session recovers cleanly", async () => {
vi.spyOn(validatorModule, "validateSession").mockImplementation(async (scanned) =>
makeAssessment(scanned.sessionId),

View File

@ -372,7 +372,7 @@ export function mutateMetadata(
// Truncate the bad-JSON sample to 200 chars (B11 invariant — full file
// could be 16KB+ and would be dropped by the sanitizer cap).
const contentSample =
content.length > 200 ? `${content.slice(0, 200)}` : content;
content.length > 200 ? content.slice(0, 200) : content;
// dataDir is `.../projects/{projectId}/sessions`; recover projectId for filtering.
const inferredProjectId = basename(dirname(dataDir));
const summary = renamed

View File

@ -107,29 +107,11 @@ export async function runRecovery(options: RecoveryManagerOptions): Promise<Reco
break;
}
} else {
const errorMessage = result.error || "Unknown error";
const errorMessage = recordRecoverySessionFailed(assessment, result);
report.errors.push({
sessionId: assessment.sessionId,
error: errorMessage,
});
// Forensic event so RCA can answer: did `ao recover` actually fix
// anything? Which sessions did it skip? B22: one event per failed
// session, not per probe step.
recordActivityEvent({
projectId: assessment.projectId,
sessionId: assessment.sessionId,
source: "recovery",
kind: "recovery.session_failed",
level: "error",
summary: `Recovery failed for session ${assessment.sessionId}: ${errorMessage}`,
data: {
action: result.action,
classification: assessment.classification,
recoveryRule: assessment.recoveryRule,
metadataStatus: assessment.metadataStatus,
errorMessage,
},
});
}
const logAction = mapActionToLogAction(result.action, result.success);
@ -153,6 +135,32 @@ export async function runRecovery(options: RecoveryManagerOptions): Promise<Reco
};
}
function recordRecoverySessionFailed(
assessment: RecoveryAssessment,
result: RecoveryResult,
): string {
const errorMessage = result.error || "Unknown error";
// Forensic event so RCA can answer: did `ao recover` actually fix
// anything? Which sessions did it skip? B22: one event per failed
// session, not per probe step.
recordActivityEvent({
projectId: assessment.projectId,
sessionId: assessment.sessionId,
source: "recovery",
kind: "recovery.session_failed",
level: "error",
summary: `Recovery failed for session ${assessment.sessionId}: ${errorMessage}`,
data: {
action: result.action,
classification: assessment.classification,
recoveryRule: assessment.recoveryRule,
metadataStatus: assessment.metadataStatus,
errorMessage,
},
});
return errorMessage;
}
function mapActionToLogAction(
action: string,
success: boolean,
@ -204,6 +212,10 @@ export async function recoverSessionById(
return result;
}
if (!result.success) {
recordRecoverySessionFailed(assessment, result);
}
const logAction = mapActionToLogAction(result.action, result.success);
writeRecoveryLog(
recoveryConfig.logPath,