mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
fix(cutter): normalize command checkpoint cancellation
Produce typed cancellation at each command checkpoint while retaining the exact opaque reason as cause. Preserve independent typed physical failures without downstream equality guesses. Validated 72 focused tests across nine files, static checks, docs and five conformance controls, including real client/service admission races and matching physical-error controls. No native Cutter engine or new public-transport reproduction claimed. Refs: #4635
This commit is contained in:
1 parent
199396f61c
commit
71929ed37c
4 files changed
+97
-4
No files matched your search
@@ -76,6 +76,9 @@ probe retries the original command. Failed acknowledgements remain pending and
|
||||
block new commands until they can be retried successfully. Status proof failures
|
||||
identify `cutter_command` and retain `status` in
|
||||
`details.diagnostics.auxiliary_operation`; malformed proof still blocks dispatch.
|
||||
Descriptor failures likewise retain the requesting operation through cleanup.
|
||||
Command admission reports typed cancellation and treats the caller's abort reason
|
||||
as opaque metadata, without inferring cleanup ownership from it.
|
||||
|
||||
## Configuration and coverage
|
||||
|
||||
|
||||
@@ -176,6 +176,13 @@ class CutterBridgeResponseFrames {
|
||||
}
|
||||
}
|
||||
|
||||
const assertCommandActive = (signal: AbortSignal | undefined): void => {
|
||||
if (signal?.aborted)
|
||||
throw new AnalysisCancelledError("cutter_command", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
};
|
||||
|
||||
const mapDiscovery = async <Input, Output>(
|
||||
inputs: readonly Input[],
|
||||
operation: (input: Input) => Promise<Output>,
|
||||
@@ -413,9 +420,9 @@ export class CutterBridgeClient {
|
||||
readonly json: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<CutterBridgeExecution> {
|
||||
input.signal?.throwIfAborted();
|
||||
assertCommandActive(input.signal);
|
||||
const descriptor = await this.#find(input.sessionId, input.signal);
|
||||
input.signal?.throwIfAborted();
|
||||
assertCommandActive(input.signal);
|
||||
const requestId = randomUUID();
|
||||
const commandRequest = serializeRequest(descriptor, {
|
||||
kind: "command",
|
||||
@@ -426,7 +433,7 @@ export class CutterBridgeClient {
|
||||
});
|
||||
await this.#recoverCompletedCommands(descriptor, input.signal);
|
||||
await this.#retryPendingAcknowledgements(descriptor);
|
||||
input.signal?.throwIfAborted();
|
||||
assertCommandActive(input.signal);
|
||||
let completionToken: string | undefined;
|
||||
let rawResponse: unknown;
|
||||
try {
|
||||
|
||||
@@ -748,7 +748,11 @@ describe("CutterBridgeClient: scenario group 4", () => {
|
||||
json: false,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "cutter_command",
|
||||
cause: controller.signal.reason,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../../src/domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it.each([
|
||||
new AnalysisInputError("opaque caller metadata"),
|
||||
new ProviderCleanupError("caller-owned", ["opaque caller resource"], {
|
||||
reason: "caller metadata",
|
||||
}),
|
||||
])(
|
||||
"keeps a typed caller abort reason opaque at command admission: $_tag",
|
||||
async (reason) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-cutter-command-cancel-",
|
||||
);
|
||||
const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: directory });
|
||||
const service = new CutterBridgeService(client);
|
||||
onTestFinished(() => service.close());
|
||||
const controller = new AbortController();
|
||||
const executing = service.execute(
|
||||
{
|
||||
session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a",
|
||||
expected_generation: 0,
|
||||
command: "ij",
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
queueMicrotask(() => controller.abort(reason));
|
||||
const result = await executing;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
operation: "cutter_command",
|
||||
cleanupIncomplete: false,
|
||||
},
|
||||
});
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.cleanupResources).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
new AnalysisOutputError("cutter_command", "physical provider failure"),
|
||||
new ProviderCleanupError("cutter", ["actual producer resource"], {
|
||||
reason: "physical producer close failure",
|
||||
}),
|
||||
])(
|
||||
"preserves a genuine typed provider failure also selected as abort reason: $_tag",
|
||||
async (physical) => {
|
||||
const controller = new AbortController();
|
||||
const service = new CutterBridgeService({
|
||||
listSessions: async () => {
|
||||
throw new Error("Unexpected discovery");
|
||||
},
|
||||
execute: async () => {
|
||||
controller.abort(physical);
|
||||
throw physical;
|
||||
},
|
||||
});
|
||||
const result = await service.execute(
|
||||
{
|
||||
session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a",
|
||||
expected_generation: 0,
|
||||
command: "ij",
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected provider failure");
|
||||
expect(result.error).toBe(physical);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user