mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 11:13:21 +00:00
refactor(application): own the finalization precedence in runOwned (#10645)
* refactor(application): own the finalization precedence in runOwned runOwned runs owned work, closes its owner and applies the documented precedence once: a recorded failure stays primary, cancellation before commit keeps the completed value as a partial observation, cancellation after commit keeps the value, and unconfirmed cleanup reports cleanup_incomplete while keeping the completed value. finishAnalysisCommand now projects its Finalized outcome; its 31 callers are unchanged. * fix(application): preserve finalization outcomes * fix(application): classify finalization by the primary error and keep cancellation through recovered publication A producer composite whose primary is a cancellation finalized as failed because the kind read only the top-level error class; it now follows primaryAnalysisError. A cancellation that arrived while a recovered value's publication failed was dropped because the signal was read before that last await; it is now attached to the publication failure as a secondary. * fix(cli): keep the initial failure snapshot whenever close itself succeeded
This commit is contained in:
1 parent
61fd6336bf
commit
2e1e6593f2
5 files changed
+704
-147
No files matched your search
@@ -459,7 +459,12 @@ failed request's physical, cancellation and cleanup facts.
|
||||
remain attached to their owning envelope while cleanup is pending and after
|
||||
confirmed release. CLI adapters should reuse
|
||||
[`finishAnalysisCommand`](https://github.com/morluto/rea/blob/main/src/cli/finishAnalysisCommand.ts) where its contract
|
||||
applies rather than duplicate close/finally handling.
|
||||
applies rather than duplicate close/finally handling. It projects
|
||||
[`runOwned`](https://github.com/morluto/rea/blob/main/src/application/runOwned.ts),
|
||||
which owns this precedence for owned work: a recorded failure stays primary,
|
||||
cancellation before commit keeps the completed value as a partial
|
||||
observation, cancellation after commit keeps the value, and unconfirmed
|
||||
cleanup reports `cleanup_incomplete` while keeping the completed value.
|
||||
- Give descriptor-backed reads an owner before opening the file. A rejected
|
||||
close must retain the exact handle and preserve any earlier failure.
|
||||
Carry [`openRegularFile`](https://github.com/morluto/rea/blob/main/src/filesystem/RegularFile.ts)'s
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"src/browser/modules/NativeModuleResolver.ts": 3,
|
||||
"src/captures/TsharkCaptureProvider.ts": 2,
|
||||
"src/cli/environmentCommands.ts": 2,
|
||||
"src/cli/finishAnalysisCommand.ts": 3,
|
||||
"src/cliJsonInput.ts": 1,
|
||||
"src/desktop/BuildComputerLongRuns.ts": 5,
|
||||
"src/desktop/DesktopBuildDoctorFacts.ts": 5,
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisProtocolError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
primaryAnalysisError,
|
||||
type AnalysisError,
|
||||
type AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { finalizedResult, runOwned } from "./runOwned.js";
|
||||
|
||||
const value: AnalysisPartialObservation = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: "evidence-1",
|
||||
};
|
||||
const reason = { caller: "stop" };
|
||||
const completedWork = async (): Promise<
|
||||
Result<AnalysisPartialObservation, AnalysisError>
|
||||
> => ok(value);
|
||||
|
||||
/** A close that aborts the caller's signal, so cancellation lands during final cleanup. */
|
||||
const closeThenAbort =
|
||||
(controller: AbortController, failure?: unknown) => async () => {
|
||||
controller.abort(reason);
|
||||
if (failure !== undefined) throw failure;
|
||||
};
|
||||
|
||||
const options = (controller: AbortController, close: () => Promise<void>) => ({
|
||||
providerId: "fixture",
|
||||
operation: "fixture_operation",
|
||||
signal: controller.signal,
|
||||
close,
|
||||
});
|
||||
|
||||
describe("runOwned precedence", () => {
|
||||
it("completes when cleanup is released and no cancellation arrives", async () => {
|
||||
const controller = new AbortController();
|
||||
const finalized = await runOwned(
|
||||
completedWork,
|
||||
options(controller, async () => undefined),
|
||||
);
|
||||
expect(finalized).toEqual({ kind: "completed", value });
|
||||
});
|
||||
|
||||
it("cancels a completed value when cancellation arrives during final cleanup before commit", async () => {
|
||||
const controller = new AbortController();
|
||||
const finalized = await runOwned(
|
||||
completedWork,
|
||||
options(controller, closeThenAbort(controller)),
|
||||
);
|
||||
expect(finalized.kind).toBe("cancelled");
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected cancellation");
|
||||
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cause).toBe(reason);
|
||||
expect(result.error.partialObservation).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps a committed value when cancellation arrives after the commit", async () => {
|
||||
const controller = new AbortController();
|
||||
const commits: unknown[] = [];
|
||||
const finalized = await runOwned(completedWork, {
|
||||
...options(controller, closeThenAbort(controller)),
|
||||
commit: async (committed) => {
|
||||
commits.push(committed);
|
||||
},
|
||||
});
|
||||
expect(commits).toEqual([value]);
|
||||
expect(finalized).toEqual({
|
||||
kind: "completed",
|
||||
value,
|
||||
lateCancellation: reason,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not commit after cancellation and reports it before commit", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(reason);
|
||||
let committed = false;
|
||||
const finalized = await runOwned(completedWork, {
|
||||
...options(controller, async () => undefined),
|
||||
commit: async () => {
|
||||
committed = true;
|
||||
},
|
||||
});
|
||||
expect(committed).toBe(false);
|
||||
expect(finalized.kind).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("keeps a recorded failure primary when cancellation arrives during final cleanup", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new AnalysisProtocolError("fixture failure");
|
||||
const finalized = await runOwned(
|
||||
async () => err(failure),
|
||||
options(controller, closeThenAbort(controller)),
|
||||
);
|
||||
expect(finalized.kind).toBe("failed");
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected failure");
|
||||
expect(primaryAnalysisError(result.error)).toBe(failure);
|
||||
expect(failure.secondaryFailures).toEqual([
|
||||
expect.objectContaining({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a completed value with unconfirmed cleanup as cleanup_incomplete keeping the value", async () => {
|
||||
const controller = new AbortController();
|
||||
const finalized = await runOwned(
|
||||
completedWork,
|
||||
options(controller, async () => {
|
||||
throw new Error("close failed");
|
||||
}),
|
||||
);
|
||||
expect(finalized.kind).toBe("completed-cleanup-retained");
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected cleanup failure");
|
||||
expect(result.error).toBeInstanceOf(ProviderCleanupError);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.partialObservation).toBe(value);
|
||||
});
|
||||
|
||||
it("keeps the primary failure's identity when cleanup also fails", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new AnalysisProtocolError("fixture failure");
|
||||
const cleanup = new ProviderCleanupError("fixture", ["fixture-run"], {
|
||||
reason: "still running",
|
||||
});
|
||||
const finalized = await runOwned(
|
||||
async () => err(failure),
|
||||
options(controller, async () => {
|
||||
throw cleanup;
|
||||
}),
|
||||
);
|
||||
expect(finalized).toMatchObject({
|
||||
kind: "failed",
|
||||
cleanup: { kind: "retained", failure: cleanup },
|
||||
});
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected failure");
|
||||
expect(primaryAnalysisError(result.error)).toBe(failure);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("types a thrown work failure as a provider adapter failure", async () => {
|
||||
const controller = new AbortController();
|
||||
const finalized = await runOwned(
|
||||
async () => {
|
||||
throw new Error("unexpected");
|
||||
},
|
||||
options(controller, async () => undefined),
|
||||
);
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected failure");
|
||||
expect(result.error._tag).toBe("ProviderAdapterError");
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies producer cancellation even when cleanup also fails", async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = new AnalysisCancelledError("fixture_operation", {
|
||||
cause: reason,
|
||||
});
|
||||
const finalized = await runOwned(async () => err(cancellation), {
|
||||
...options(controller, async () => {
|
||||
throw new Error("still running");
|
||||
}),
|
||||
});
|
||||
expect(finalized.kind).toBe("cancelled");
|
||||
if (finalized.kind !== "cancelled") throw new Error("expected cancellation");
|
||||
expect(finalized.cleanup.kind).toBe("retained");
|
||||
expect(primaryAnalysisError(finalized.error)).toBe(cancellation);
|
||||
});
|
||||
|
||||
it("classifies a producer composite whose primary is a cancellation as cancelled", async () => {
|
||||
const controller = new AbortController();
|
||||
const cancellation = new AnalysisCancelledError("fixture_operation", {
|
||||
cause: reason,
|
||||
});
|
||||
const cleanup = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const composite = analysisErrorWithCleanupFailure(
|
||||
cancellation,
|
||||
cleanup,
|
||||
"fixture_operation",
|
||||
).bindCleanupDependencies([cancellation, cleanup]);
|
||||
const finalized = await runOwned(
|
||||
async () => err(composite),
|
||||
options(controller, async () => {
|
||||
cleanup.resolveCleanup();
|
||||
}),
|
||||
);
|
||||
expect(finalized).toMatchObject({
|
||||
kind: "cancelled",
|
||||
error: composite,
|
||||
cleanup: { kind: "released" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a failure with a later cancellation classified as failed", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new AnalysisProtocolError("fixture failure");
|
||||
const finalized = await runOwned(
|
||||
async () => err(failure),
|
||||
options(controller, closeThenAbort(controller)),
|
||||
);
|
||||
expect(finalized.kind).toBe("failed");
|
||||
expect(failure.secondaryFailures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps producer cleanup retained when close does not resolve it", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const finalized = await runOwned(
|
||||
async () => err(failure),
|
||||
options(controller, async () => undefined),
|
||||
);
|
||||
expect(finalized).toMatchObject({
|
||||
kind: "failed",
|
||||
error: failure,
|
||||
cleanup: { kind: "retained", failure },
|
||||
});
|
||||
});
|
||||
|
||||
it("retains completed work when publication fails", async () => {
|
||||
const finalized = await runOwned(completedWork, {
|
||||
...options(new AbortController(), async () => undefined),
|
||||
commit: async () => {
|
||||
throw new Error("publication failed");
|
||||
},
|
||||
});
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected publication failure");
|
||||
expect(result.error.partialObservation).toBe(value);
|
||||
});
|
||||
|
||||
it("publishes recovered completion after cleanup resolves", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const published: AnalysisPartialObservation[] = [];
|
||||
const finalized = await runOwned(async () => err(failure), {
|
||||
...options(controller, async () => {
|
||||
failure.resolveCleanup();
|
||||
}),
|
||||
recoverCompleted: (_error, observed) =>
|
||||
observed.cleanupResolvedByClose ? { value } : undefined,
|
||||
commit: async (completed) => {
|
||||
published.push(completed);
|
||||
},
|
||||
});
|
||||
expect(published).toEqual([value]);
|
||||
expect(finalized).toEqual({ kind: "completed", value });
|
||||
});
|
||||
|
||||
it("keeps a recovered value when cancellation arrives during its publication", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const finalized = await runOwned(async () => err(failure), {
|
||||
...options(controller, async () => {
|
||||
failure.resolveCleanup();
|
||||
}),
|
||||
recoverCompleted: () => ({ value }),
|
||||
commit: async () => {
|
||||
controller.abort(reason);
|
||||
},
|
||||
});
|
||||
expect(finalized).toEqual({
|
||||
kind: "completed",
|
||||
value,
|
||||
lateCancellation: reason,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a cancellation that arrives while a recovered publication fails", async () => {
|
||||
const controller = new AbortController();
|
||||
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const publication = new AnalysisProtocolError("publication failed");
|
||||
const finalized = await runOwned(async () => err(failure), {
|
||||
...options(controller, async () => {
|
||||
failure.resolveCleanup();
|
||||
}),
|
||||
recoverCompleted: () => ({ value }),
|
||||
commit: async () => {
|
||||
controller.abort(reason);
|
||||
throw publication;
|
||||
},
|
||||
});
|
||||
expect(finalized.kind).toBe("failed");
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected publication failure");
|
||||
expect(primaryAnalysisError(result.error)).toBe(publication);
|
||||
expect(publication.secondaryFailures).toEqual([
|
||||
expect.objectContaining({ _tag: "AnalysisCancelledError", cause: reason }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains a recovered value when its publication fails", async () => {
|
||||
const failure = new ProviderCleanupError("fixture", ["owned-process"], {
|
||||
reason: "pending",
|
||||
});
|
||||
const finalized = await runOwned(async () => err(failure), {
|
||||
...options(new AbortController(), async () => {
|
||||
failure.resolveCleanup();
|
||||
}),
|
||||
recoverCompleted: () => ({ value }),
|
||||
commit: async () => {
|
||||
throw new Error("publication failed");
|
||||
},
|
||||
});
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) throw new Error("expected publication failure");
|
||||
expect(result.error.partialObservation).toBe(value);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
AnalysisError,
|
||||
primaryAnalysisError,
|
||||
type AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
cancelledBy,
|
||||
isAborted,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** What closing the work's owner established. */
|
||||
export type CleanupOutcome =
|
||||
| { readonly kind: "released" }
|
||||
| { readonly kind: "retained"; readonly failure: AnalysisError };
|
||||
|
||||
/**
|
||||
* Work, owner cleanup and cancellation composed by one precedence rule:
|
||||
* - a recorded failure stays primary; a later cancellation is attached to it;
|
||||
* - cancellation observed before commit turns a completed value into a
|
||||
* cancellation that keeps the value as its partial observation;
|
||||
* - cancellation observed after commit keeps the completed value;
|
||||
* - unconfirmed cleanup never replaces the primary. A completed value whose
|
||||
* cleanup is unconfirmed is a `cleanup_incomplete` error that keeps the
|
||||
* value as its partial observation (the documented one-shot contract).
|
||||
*/
|
||||
export type Finalized<Value> =
|
||||
| {
|
||||
readonly kind: "completed";
|
||||
readonly value: Value;
|
||||
/** A cancellation observed after the commit cutoff. */
|
||||
readonly lateCancellation?: unknown;
|
||||
}
|
||||
| {
|
||||
readonly kind: "completed-cleanup-retained";
|
||||
readonly value: Value;
|
||||
readonly error: AnalysisError;
|
||||
}
|
||||
| {
|
||||
readonly kind: "cancelled";
|
||||
readonly error: AnalysisError;
|
||||
readonly cleanup: CleanupOutcome;
|
||||
}
|
||||
| {
|
||||
readonly kind: "failed";
|
||||
readonly error: AnalysisError;
|
||||
readonly cleanup: CleanupOutcome;
|
||||
};
|
||||
|
||||
/** A completed value recovered from a failure whose only cause was cleanup. */
|
||||
export interface RecoveredCompletion<Value> {
|
||||
readonly value: Value;
|
||||
/** Diagnostics a later pre-commit cancellation carries, such as the resolved cleanup history. */
|
||||
readonly cancellationDiagnostics?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
|
||||
export interface RunOwnedOptions<Value> {
|
||||
readonly providerId: string;
|
||||
readonly operation: string;
|
||||
readonly signal: AbortSignal;
|
||||
/** Releases everything the work acquired; awaited before the signal is read. */
|
||||
readonly close: () => Promise<void>;
|
||||
/** Names the owned resources when a non-REA close failure names none. */
|
||||
readonly fallbackResource?: string;
|
||||
/**
|
||||
* Publishes a completed value. Cancellation observed before it runs cancels
|
||||
* the work; cancellation observed after it keeps the committed value.
|
||||
*/
|
||||
readonly commit?: (value: Value) => Promise<void>;
|
||||
/** Observe the primary outcome before close may change its cleanup status. */
|
||||
readonly inspectPrimary?: (primary: Result<Value, AnalysisError>) => void;
|
||||
/** Recover a completed value from a primary failure after close settled. */
|
||||
readonly recoverCompleted?: (
|
||||
primary: AnalysisError,
|
||||
observed: {
|
||||
/** The primary reported unconfirmed cleanup before close and not after. */
|
||||
readonly cleanupResolvedByClose: boolean;
|
||||
readonly cleanup: CleanupOutcome;
|
||||
},
|
||||
) => RecoveredCompletion<Value> | undefined;
|
||||
}
|
||||
|
||||
const closeFailure = (
|
||||
cause: unknown,
|
||||
options: Pick<
|
||||
RunOwnedOptions<unknown>,
|
||||
"providerId" | "operation" | "fallbackResource"
|
||||
>,
|
||||
): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
const observation =
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
options.fallbackResource ?? `${options.operation} resources`,
|
||||
)
|
||||
: {
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
resources: [],
|
||||
};
|
||||
return new ProviderCleanupError(
|
||||
options.providerId,
|
||||
observation.resources,
|
||||
{ reason: observation.reason },
|
||||
{ cause, operation: options.operation },
|
||||
);
|
||||
};
|
||||
|
||||
/** Run owned work, close its owner, and apply the finalization precedence once. */
|
||||
export const runOwned = async <Value extends AnalysisPartialObservation>(
|
||||
work: () => Promise<Result<Value, AnalysisError>>,
|
||||
options: RunOwnedOptions<Value>,
|
||||
): Promise<Finalized<Value>> => {
|
||||
let primary: Result<Value, AnalysisError>;
|
||||
try {
|
||||
primary = await work();
|
||||
} catch (cause) {
|
||||
primary = {
|
||||
ok: false,
|
||||
error:
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(options.providerId, options.operation, {
|
||||
cause,
|
||||
}),
|
||||
};
|
||||
}
|
||||
let committed = false;
|
||||
const commit = async (
|
||||
value: Value,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
try {
|
||||
await options.commit!(value);
|
||||
committed = true;
|
||||
return { ok: true, value };
|
||||
} catch (cause) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
cause instanceof AnalysisError
|
||||
? cause.retainPartialObservation(value)
|
||||
: new ProviderAdapterError(options.providerId, options.operation, {
|
||||
cause,
|
||||
partialObservation: value,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
if (primary.ok && options.commit !== undefined && !options.signal.aborted)
|
||||
primary = await commit(primary.value);
|
||||
options.inspectPrimary?.(primary);
|
||||
const hadIncompleteCleanup = !primary.ok && primary.error.cleanupIncomplete;
|
||||
let cleanup: CleanupOutcome = { kind: "released" };
|
||||
try {
|
||||
// Owners may resolve the primary's retained cleanup during close; only
|
||||
// they can confirm it, so its status is read again afterwards.
|
||||
await options.close();
|
||||
} catch (cause) {
|
||||
cleanup = { kind: "retained", failure: closeFailure(cause, options) };
|
||||
}
|
||||
const signal = options.signal;
|
||||
let outcome:
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly value: Value;
|
||||
readonly diagnostics?: Readonly<Record<string, JsonValue>>;
|
||||
}
|
||||
| { readonly ok: false; readonly error: AnalysisError } = primary;
|
||||
if (!primary.ok) {
|
||||
if (isAborted(signal))
|
||||
analysisErrorWithCancellation(
|
||||
primary.error,
|
||||
cancelledBy(options.operation, signal),
|
||||
);
|
||||
const recovered = options.recoverCompleted?.(primary.error, {
|
||||
cleanupResolvedByClose:
|
||||
hadIncompleteCleanup && !primary.error.cleanupIncomplete,
|
||||
cleanup,
|
||||
});
|
||||
if (recovered !== undefined)
|
||||
outcome = {
|
||||
ok: true,
|
||||
value: recovered.value,
|
||||
...(recovered.cancellationDiagnostics === undefined
|
||||
? {}
|
||||
: { diagnostics: recovered.cancellationDiagnostics }),
|
||||
};
|
||||
}
|
||||
if (
|
||||
outcome.ok &&
|
||||
!primary.ok &&
|
||||
options.commit !== undefined &&
|
||||
!signal.aborted
|
||||
) {
|
||||
const publication = await commit(outcome.value);
|
||||
if (!publication.ok) {
|
||||
// Publication is the last awaited step; a cancellation during it stays
|
||||
// attached to the publication failure without replacing it.
|
||||
outcome = publication;
|
||||
if (isAborted(signal))
|
||||
analysisErrorWithCancellation(
|
||||
publication.error,
|
||||
cancelledBy(options.operation, signal),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
cleanup.kind === "released" &&
|
||||
!outcome.ok &&
|
||||
outcome.error.cleanupIncomplete
|
||||
)
|
||||
cleanup = { kind: "retained", failure: outcome.error };
|
||||
// The kind follows the primary failure, so a producer's composite whose
|
||||
// primary is a cancellation is cancelled, and a failure that only retains a
|
||||
// later cancellation as a secondary stays failed.
|
||||
let cancelled =
|
||||
!outcome.ok &&
|
||||
primaryAnalysisError(outcome.error) instanceof AnalysisCancelledError;
|
||||
if (outcome.ok && isAborted(signal)) {
|
||||
if (committed && cleanup.kind === "released")
|
||||
return {
|
||||
kind: "completed",
|
||||
value: outcome.value,
|
||||
lateCancellation: signal.reason,
|
||||
};
|
||||
if (!committed) {
|
||||
cancelled = true;
|
||||
outcome = {
|
||||
ok: false,
|
||||
error: cancelledBy(options.operation, signal, {
|
||||
partialObservation: outcome.value,
|
||||
...(outcome.diagnostics === undefined
|
||||
? {}
|
||||
: { diagnostics: outcome.diagnostics }),
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (cleanup.kind === "retained") {
|
||||
if (outcome.ok)
|
||||
return {
|
||||
kind: "completed-cleanup-retained",
|
||||
value: outcome.value,
|
||||
error: cleanup.failure.retainPartialObservation(outcome.value),
|
||||
};
|
||||
return {
|
||||
kind: cancelled ? "cancelled" : "failed",
|
||||
error:
|
||||
cleanup.failure === outcome.error
|
||||
? outcome.error
|
||||
: analysisErrorWithCleanupFailure(
|
||||
outcome.error,
|
||||
cleanup.failure,
|
||||
options.operation,
|
||||
).bindCleanupDependencies([outcome.error, cleanup.failure]),
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
if (outcome.ok) return { kind: "completed", value: outcome.value };
|
||||
return {
|
||||
kind: cancelled ? "cancelled" : "failed",
|
||||
error: outcome.error,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
|
||||
/** The single Result a caller sees for a finalized outcome. */
|
||||
export const finalizedResult = <Value>(
|
||||
finalized: Finalized<Value>,
|
||||
): Result<Value, AnalysisError> =>
|
||||
finalized.kind === "completed"
|
||||
? { ok: true, value: finalized.value }
|
||||
: { ok: false, error: finalized.error };
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import {
|
||||
finalizedResult,
|
||||
runOwned,
|
||||
type RecoveredCompletion,
|
||||
} from "../application/runOwned.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
type AnalysisPartialObservation,
|
||||
AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
projectAnalysisError,
|
||||
projectAnalysisErrorWithoutCapturedOutput,
|
||||
} from "../domain/analysisErrorProjection.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { dispatchQueuedInterrupts } from "../process/queuedInterrupts.js";
|
||||
@@ -43,69 +43,77 @@ export const finishAnalysisCommand = async <
|
||||
// reads this result, so guidance needing a continuing session is omitted.
|
||||
const projectFailure = (error: AnalysisError) =>
|
||||
projectAnalysisError(options.redactFailure?.(error) ?? error, "invocation");
|
||||
let result: Result<Value, AnalysisError>;
|
||||
let cleanup: AnalysisError | undefined;
|
||||
try {
|
||||
result = await run();
|
||||
} catch (cause) {
|
||||
result = {
|
||||
ok: false,
|
||||
error:
|
||||
cause instanceof AnalysisError
|
||||
? cause
|
||||
: new ProviderAdapterError(providerId, canonicalOperation, { cause }),
|
||||
};
|
||||
}
|
||||
// Some one-shot commands promise the original failure snapshot even when
|
||||
// close retries and mutates the producer's live cleanup status.
|
||||
const initialFailure =
|
||||
!result.ok && options.failureProjection === "initial"
|
||||
? projectFailure(result.error)
|
||||
: undefined;
|
||||
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
|
||||
const initialDiagnosticFailure =
|
||||
initialFailure !== undefined &&
|
||||
!result.ok &&
|
||||
result.error.capturedOutput !== undefined
|
||||
? projectAnalysisErrorWithoutCapturedOutput(
|
||||
options.redactFailure?.(result.error) ?? result.error,
|
||||
"invocation",
|
||||
)
|
||||
: initialFailure;
|
||||
try {
|
||||
// Resource owners may resolve their retained errors during close. This
|
||||
// adapter cannot infer resolution of a producer's earlier cleanup failure.
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) cleanup = cause;
|
||||
else {
|
||||
const observation =
|
||||
cause instanceof ArtifactReaderFailure
|
||||
? ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
fallbackResource ?? `${canonicalOperation} resources`,
|
||||
)
|
||||
: {
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
resources: [],
|
||||
};
|
||||
cleanup = new ProviderCleanupError(
|
||||
providerId,
|
||||
observation.resources,
|
||||
{ reason: observation.reason },
|
||||
{ cause, operation: canonicalOperation },
|
||||
);
|
||||
}
|
||||
}
|
||||
await dispatchQueuedInterrupts();
|
||||
if (!result.ok && signal.aborted)
|
||||
analysisErrorWithCancellation(
|
||||
result.error,
|
||||
new AnalysisCancelledError(canonicalOperation, { cause: signal.reason }),
|
||||
);
|
||||
if (initialFailure !== undefined && cleanup === undefined && !result.ok) {
|
||||
// Close may mutate cleanup state; preserve the snapshot and add later failures.
|
||||
const secondary = projectFailure(result.error).details?.secondary_failures;
|
||||
let initialFailure: ReturnType<typeof projectAnalysisError> | undefined;
|
||||
let initialDiagnosticFailure: typeof initialFailure;
|
||||
let closeFailed = false;
|
||||
const finalized = await runOwned(run, {
|
||||
providerId,
|
||||
operation: canonicalOperation,
|
||||
signal,
|
||||
...(fallbackResource === undefined ? {} : { fallbackResource }),
|
||||
close: async () => {
|
||||
try {
|
||||
await service.close();
|
||||
} catch (cause) {
|
||||
closeFailed = true;
|
||||
throw cause;
|
||||
} finally {
|
||||
await dispatchQueuedInterrupts();
|
||||
}
|
||||
},
|
||||
inspectPrimary: (primary) => {
|
||||
if (primary.ok || options.failureProjection !== "initial") return;
|
||||
initialFailure = projectFailure(primary.error);
|
||||
initialDiagnosticFailure =
|
||||
primary.error.capturedOutput === undefined
|
||||
? initialFailure
|
||||
: projectAnalysisErrorWithoutCapturedOutput(
|
||||
options.redactFailure?.(primary.error) ?? primary.error,
|
||||
"invocation",
|
||||
);
|
||||
},
|
||||
recoverCompleted: (
|
||||
primary,
|
||||
observed,
|
||||
): RecoveredCompletion<Value> | undefined => {
|
||||
if (initialFailure !== undefined) return undefined;
|
||||
if (
|
||||
observed.cleanupResolvedByClose &&
|
||||
observed.cleanup.kind === "released"
|
||||
) {
|
||||
const recovered = options.recoverResolvedCleanup?.(primary);
|
||||
if (recovered !== undefined) return { value: recovered };
|
||||
}
|
||||
if (
|
||||
primary instanceof ProviderCleanupError &&
|
||||
!primary.cleanupIncomplete &&
|
||||
primary.cleanupPrimary === undefined &&
|
||||
primary.completedObservation !== undefined
|
||||
) {
|
||||
const completed = options.recoverCompletedObservation?.(
|
||||
primary.completedObservation,
|
||||
);
|
||||
if (completed !== undefined)
|
||||
return {
|
||||
value: completed,
|
||||
cancellationDiagnostics: {
|
||||
cleanup_history: projectFailure(primary),
|
||||
},
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const result = finalizedResult(finalized);
|
||||
if (result.ok) return ok(result.value);
|
||||
const failure = projectFailure(result.error);
|
||||
if (initialFailure === undefined) return err(failure);
|
||||
if (!closeFailed) {
|
||||
// Close may mutate cleanup state, including resolving the cleanup of a
|
||||
// cancelled primary; preserve the snapshot and add later failures.
|
||||
const secondary = failure.details?.secondary_failures;
|
||||
return err(
|
||||
secondary === undefined
|
||||
? initialFailure
|
||||
@@ -118,83 +126,19 @@ export const finishAnalysisCommand = async <
|
||||
},
|
||||
);
|
||||
}
|
||||
if (
|
||||
initialFailure === undefined &&
|
||||
cleanup === undefined &&
|
||||
!result.ok &&
|
||||
hadIncompleteCleanup &&
|
||||
!result.error.cleanupIncomplete
|
||||
) {
|
||||
const recovered = options.recoverResolvedCleanup?.(result.error);
|
||||
if (recovered !== undefined) result = ok(recovered);
|
||||
}
|
||||
if (
|
||||
initialFailure === undefined &&
|
||||
!result.ok &&
|
||||
result.error instanceof ProviderCleanupError &&
|
||||
!result.error.cleanupIncomplete &&
|
||||
result.error.cleanupPrimary === undefined &&
|
||||
result.error.completedObservation !== undefined
|
||||
) {
|
||||
const completed = options.recoverCompletedObservation?.(
|
||||
result.error.completedObservation,
|
||||
);
|
||||
if (completed !== undefined) {
|
||||
const recoveredCleanup = result.error;
|
||||
result = signal.aborted
|
||||
? {
|
||||
ok: false,
|
||||
error: new AnalysisCancelledError(canonicalOperation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: completed,
|
||||
diagnostics: {
|
||||
cleanup_history: projectFailure(recoveredCleanup),
|
||||
},
|
||||
}),
|
||||
}
|
||||
: { ok: true, value: completed };
|
||||
}
|
||||
}
|
||||
if (result.ok && signal.aborted)
|
||||
result = {
|
||||
ok: false,
|
||||
error: new AnalysisCancelledError(canonicalOperation, {
|
||||
cause: signal.reason,
|
||||
partialObservation: result.value,
|
||||
}),
|
||||
};
|
||||
if (cleanup !== undefined) {
|
||||
if (result.ok) cleanup.retainPartialObservation(result.value);
|
||||
result = {
|
||||
ok: false,
|
||||
error: result.ok
|
||||
? cleanup
|
||||
: analysisErrorWithCleanupFailure(
|
||||
result.error,
|
||||
cleanup,
|
||||
canonicalOperation,
|
||||
).bindCleanupDependencies([result.error, cleanup]),
|
||||
};
|
||||
}
|
||||
if (result.ok) return ok(result.value);
|
||||
const failure = projectFailure(result.error);
|
||||
const diagnostics = failure.details?.diagnostics;
|
||||
return err(
|
||||
initialFailure === undefined || cleanup === undefined
|
||||
? failure
|
||||
: {
|
||||
...failure,
|
||||
details: {
|
||||
...failure.details,
|
||||
diagnostics: {
|
||||
...(typeof diagnostics === "object" &&
|
||||
diagnostics !== null &&
|
||||
!Array.isArray(diagnostics)
|
||||
? diagnostics
|
||||
: {}),
|
||||
primary_error: initialDiagnosticFailure ?? initialFailure,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
return err({
|
||||
...failure,
|
||||
details: {
|
||||
...failure.details,
|
||||
diagnostics: {
|
||||
...(typeof diagnostics === "object" &&
|
||||
diagnostics !== null &&
|
||||
!Array.isArray(diagnostics)
|
||||
? diagnostics
|
||||
: {}),
|
||||
primary_error: initialDiagnosticFailure ?? initialFailure,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user