fix: retain status observations when session cleanup fails

This commit is contained in:
morluto committed 2026-10-24 01:47:31 +08:00
1 parent aedd394471
commit 37122745b6
3 files changed
+135 -25

No files matched your search

+1 -2
View File
@@ -1,4 +1,3 @@
import type { Evidence } from "../domain/evidence.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
@@ -33,7 +32,7 @@ export const withSessionCleanup = async <Value>(
/** Project typed one-shot outcomes only after execution and cleanup settle. */
export const directAnalysisCleanupFailure = (
result: Result<Evidence, AnalysisError>,
result: Result<JsonValue, AnalysisError>,
error: AnalysisError,
): JsonValue => {
const cleanup = projectAnalysisError(error);
+30 -23
View File
@@ -10,6 +10,11 @@ import { sqliteDatabaseCapability } from "./sqlite/SqliteDatabaseCapability.js";
import { CURRENT_ENTRYPOINT_PATH } from "../process/CurrentEntrypointPath.js";
import { createCatalogIdentity } from "../catalogIdentity.js";
import { compactPresentationForMcpInputSchemaProfile } from "../config/mcpInputSchemaProfile.js";
import {
directAnalysisCleanupFailure,
withSessionCleanup,
} from "./DirectAnalysisCleanup.js";
import { ok } from "../domain/result.js";
/** Read target-free provider status and operation availability. */
export const runSessionStatus = async (
@@ -22,28 +27,30 @@ export const runSessionStatus = async (
const config = dependencies.readConfiguration();
if (!config.ok) return { error: projectAnalysisError(config.error) };
const session = await dependencies.createBinarySession(config.value, logger);
try {
const status = jsonObjectSchema.parse(await session.status());
return {
...status,
capabilities: [
...providerCapabilitySet.array().parse(status.capabilities),
...providerCapabilitySets([
sqliteDatabaseCapability(process.versions.node),
]),
],
server_identity: createServerIdentity({
startedAt: new Date().toISOString(),
commandPath: CURRENT_ENTRYPOINT_PATH,
catalog: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile(
config.value.mcpInputSchemaProfile,
return withSessionCleanup<JsonValue>(
session,
async () => {
const status = jsonObjectSchema.parse(await session.status());
return {
...status,
capabilities: [
...providerCapabilitySet.array().parse(status.capabilities),
...providerCapabilitySets([
sqliteDatabaseCapability(process.versions.node),
]),
],
server_identity: createServerIdentity({
startedAt: new Date().toISOString(),
commandPath: CURRENT_ENTRYPOINT_PATH,
catalog: createCatalogIdentity(
compactPresentationForMcpInputSchemaProfile(
config.value.mcpInputSchemaProfile,
),
config.value.mcpOutputSchemaProfile ?? "full",
),
config.value.mcpOutputSchemaProfile ?? "full",
),
}),
};
} finally {
await session.close();
}
}),
};
},
(status, cleanup) => directAnalysisCleanupFailure(ok(status), cleanup),
);
};
@@ -0,0 +1,104 @@
import { Cli } from "incur";
import { expect, it, vi } from "vitest";
import { runSessionStatus } from "../../../src/application/DirectAnalysisStatus.js";
import { registerUtilityCommands } from "../../../src/cli/utilityCommands.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { isCliOperationFailure } from "../../../src/cliLogging.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok } from "../../../src/domain/result.js";
import { silentLogger } from "../../../src/logger.js";
import { createTestBinarySession } from "../../fixtures/binarySession.js";
import { observed } from "../../fixtures/analysisExecution.js";
const createStatusSession = () =>
createTestBinarySession(() => ({
close: async () => ok(null),
execute: async () => observed(null),
}));
it.each(["capabilities", "providers"])(
"reports failed %s finalization with the completed status",
async (command) => {
const session = createStatusSession();
const cleanup = new ProviderCleanupError(
"fixture",
["retained profile reader"],
{ reason: "profile reader close failed" },
{ remediationAction: "Retry the retained profile reader cleanup." },
);
const close = vi
.spyOn(session, "close")
.mockResolvedValueOnce(err(cleanup));
const cli = Cli.create({ name: "rea" });
registerUtilityCommands(
cli,
silentLogger,
{},
{
runSessionStatus: () =>
runSessionStatus({
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
}),
runProviderAnalysis: async () => null,
},
);
let stdout = "";
try {
await cli.serve([command, "--json"], {
env: {},
stdout: (text) => {
stdout += text;
},
exit: () => {},
});
const output: unknown = JSON.parse(stdout);
expect(output).toMatchObject({
code: "cleanup_incomplete",
remediation: { action: "Retry the retained profile reader cleanup." },
details: {
resources: ["retained profile reader"],
partial_observation: {
capabilities: expect.any(Array),
server_identity: expect.any(Object),
},
},
});
expect(isCliOperationFailure(output)).toBe(true);
expect(close).toHaveBeenCalledTimes(1);
} finally {
close.mockRestore();
await session.close();
}
},
);
it.each([false, true])(
"keeps status failure when final cleanup also fails: %s",
async (cleanupFails) => {
const session = createStatusSession();
const failure = new Error("status observation failed");
const cleanup = new ProviderCleanupError("fixture", ["reader"], {});
const status = vi.spyOn(session, "status").mockRejectedValueOnce(failure);
const close = vi
.spyOn(session, "close")
.mockResolvedValueOnce(cleanupFails ? err(cleanup) : ok(null));
try {
const result = runSessionStatus({
readConfiguration: () => parseConfig({}),
createBinarySession: () => session,
});
if (cleanupFails)
await expect(result).rejects.toMatchObject({
cause: failure,
errors: [failure, cleanup],
});
else await expect(result).rejects.toBe(failure);
expect(close).toHaveBeenCalledTimes(1);
} finally {
status.mockRestore();
close.mockRestore();
await session.close();
}
},
);