fix(observation): freeze retained incomplete observations

A JSON partial stayed a live provider object, so a later mutation could
make projection throw. The gate now keeps one frozen snapshot and still
drops a non-JSON partial without copying it.

Co-authored-by: morluto <morluto@users.noreply.github.com>
This commit is contained in:
Cursor Agentandmorluto committed 2026-10-20 19:28:59 +00:00
1 parent 134f6271ee
commit 97abf177e2
3 files changed
+45 -11

No files matched your search

@@ -136,3 +136,25 @@ it("clears a non-JSON partial from the cleanup primary projection returns after
"partial_observation",
);
});
it("freezes a retained partial so a later mutation cannot break projection", () => {
const partial = { steps: [] as unknown[] };
const primary = new AnalysisCancelledError("capture_browser_scenario", {
cause: { requested: "stop during capture" },
partialObservation: partial as never,
});
keepIncompleteObservation(
primary,
"capture_browser_scenario",
"unused because the partial is JSON",
);
partial.steps.push(undefined);
expect(primary.partialObservation).toEqual({ steps: [] });
expect(Object.isFrozen(primary.partialObservation)).toBe(true);
expect(primary.secondaryFailures).toHaveLength(0);
expect(projectAnalysisError(primary)).toMatchObject({
code: "cancelled",
details: { partial_observation: { steps: [] } },
});
});
+22 -10
View File
@@ -9,7 +9,14 @@ import {
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import type { Evidence } from "../domain/evidence.js";
import { jsonValueValidationIssue } from "../domain/jsonValue.js";
import {
freezeJsonSnapshot,
isImmutableJsonSnapshot,
} from "../domain/immutableJson.js";
import {
jsonValueSchema,
jsonValueValidationIssue,
} from "../domain/jsonValue.js";
import { projectInputIssues } from "../domain/inputIssueProjection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@@ -97,6 +104,8 @@ export const completeProviderEvidence = (
/**
* Retain an incomplete observation when projection can serialize it.
* A JSON partial is replaced by one frozen snapshot, so a later mutation
* cannot make projection throw and projection does not copy it again.
* A partial that is not JSON is cleared from the returned error and from
* every cleanup primary projection can select later. Each cleared error
* records an output failure, leaving cancellation or timeout primary.
@@ -111,15 +120,18 @@ export const keepIncompleteObservation = (
while (current !== undefined && !seen.has(current)) {
seen.add(current);
const partial = current.partialObservation;
// Validate without cloning. Projection serializes a kept partial later.
if (
partial !== undefined &&
jsonValueValidationIssue(partial) !== undefined
) {
current.partialObservation = undefined;
current.retainSecondaryFailure(
new AnalysisOutputError(operation, reason),
);
if (partial !== undefined && !isImmutableJsonSnapshot(partial)) {
// Validate without cloning. Copy only a partial that projection keeps.
if (jsonValueValidationIssue(partial) === undefined) {
current.partialObservation = freezeJsonSnapshot(
jsonValueSchema.parse(partial),
) as typeof partial;
} else {
current.partialObservation = undefined;
current.retainSecondaryFailure(
new AnalysisOutputError(operation, reason),
);
}
}
current = current.cleanupPrimary;
}
@@ -22,7 +22,7 @@ export const expectKeptPartial = (
partial: unknown,
code: "cancelled" | "provider_timeout" = "cancelled",
): void => {
expect(error.partialObservation).toBe(partial);
expect(error.partialObservation).toEqual(partial);
expect(error.secondaryFailures).toHaveLength(0);
expect(projectAnalysisError(error)).toMatchObject({
code,