fix(core): cover single-session recovery failures
This commit is contained in:
parent
4618fc4c6b
commit
f254117f6e
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue