fix(evidence): report partial workflow question publication (#8848)

* fix(evidence): report partial workflow question publication

* fix: project nested recovery for caller lifetime
This commit is contained in:
morluto authored and GitHub committed 2026-11-05 01:49:36 +08:00
1 parent b97023809f
commit 6df2ac1d7e
9 files changed
+363 -27

No files matched your search

+1
View File
@@ -514,6 +514,7 @@ const runAnalysis = async (
result: evidence.normalized_result,
evidenceId: evidence.evidence_id,
recordUnknown: (unknown) => session.recordUnknown(unknown),
evidenceRetained: true,
});
if (!unknowns.ok)
return err(unknowns.error.retainPartialObservation(evidence));
+70 -1
View File
@@ -13,7 +13,11 @@ import {
createAnalysisExecution,
type AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { nativeApiBoundarySchema } from "../domain/native/nativeApiBoundary.js";
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
import { functionDossierSchema } from "../domain/native/functionDossier.js";
@@ -671,6 +675,71 @@ describe("workflow residual question retention", () => {
});
});
it("reports a retained workflow and the committed prefix when Unknown publication fails", () => {
const records = new InvestigationRecords();
const result = { residual_unknowns: ["First?", "First?", "Second?"] };
const evidence = createWorkflowEvidence({
target: undefined,
operation: "trace_feature",
parameters: {},
result,
upstreamProfile: undefined,
upstreamLimitations: [],
});
expect(records.recordEvidence(evidence).ok).toBe(true);
const failure = new AnalysisResourceConstraintError(
"record_unknown",
"capacity",
"full",
null,
);
const outcome = recordWorkflowUnknowns({
name: "trace_feature",
result,
evidenceId: evidence.evidence_id,
evidenceRetained: true,
recordUnknown: (input) =>
input.question.startsWith("Second?")
? err(failure)
: records.recordUnknown(input),
});
expect(outcome.ok).toBe(false);
if (outcome.ok) throw new Error("expected failure");
const projected = projectAnalysisError(outcome.error);
expect(projected.details?.workflow_unknown_publication).toEqual({
evidence_id: evidence.evidence_id,
evidence_retention: "retained",
total_questions: 3,
created_questions: 1,
existing_questions: 1,
next_question_index: 2,
});
expect(projected.remediation.action).toContain("get_evidence_bundle");
const invocation = projectAnalysisError(
outcome.error.retainPartialObservation(evidence),
"invocation",
);
expect(invocation.details?.workflow_unknown_publication).toMatchObject({
evidence_retention: "invocation-only",
});
expect(invocation.details?.partial_observation).toEqual(evidence);
expect(invocation.remediation.action).not.toContain("get_evidence_bundle");
expect(records.listUnknowns()).toHaveLength(1);
expect(records.evidenceById(evidence.evidence_id)?.normalized_result).toEqual(
result,
);
expect(
recordWorkflowUnknowns({
name: "trace_feature",
result,
evidenceId: evidence.evidence_id,
evidenceRetained: true,
recordUnknown: (input) => records.recordUnknown(input),
}).ok,
).toBe(true);
expect(records.listUnknowns()).toHaveLength(2);
});
it.each([
" Which boundary? ",
"\nWhich boundary?\t",
+33 -10
View File
@@ -8,9 +8,10 @@ import type {
AnalysisOperation,
AnalysisOperationPort,
} from "./AnalysisProvider.js";
import { ok } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import type { EnhancedResult } from "./EnhancedToolTypes.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { WorkflowSnapshotRecordInput } from "./binary/BinarySessionRecords.js";
import {
analysisProfileSchema,
@@ -328,6 +329,8 @@ interface WorkflowUnknownInput {
readonly result: JsonValue;
readonly evidenceId: string;
readonly recordUnknown: UnknownRegistryPort["recordUnknown"] | undefined;
/** The caller confirmed the completed Evidence is in its investigation. */
readonly evidenceRetained?: boolean;
}
/** Retain residual workflow questions using the shared investigation policy. */
@@ -336,6 +339,7 @@ export const recordWorkflowUnknowns = ({
result,
evidenceId,
recordUnknown,
evidenceRetained,
}: WorkflowUnknownInput):
| ReturnType<UnknownRegistryPort["recordUnknown"]>
| { readonly ok: true; readonly value: null } => {
@@ -350,7 +354,27 @@ export const recordWorkflowUnknowns = ({
!Array.isArray(result.residual_unknowns)
)
return { ok: true, value: null };
for (const question of result.residual_unknowns) {
const questions = result.residual_unknowns;
const total = questions.reduce<number>(
(count, value) => count + (typeof value === "string" ? 1 : 0),
0,
);
let created = 0;
let existing = 0;
const stopped = (error: AnalysisError, index: number) =>
err(
error.retainWorkflowUnknownPublication({
evidence_id: evidenceId,
evidence_retention:
evidenceRetained === true ? "retained" : "unverified",
total_questions: total,
created_questions: created,
existing_questions: existing,
next_question_index: index,
}),
);
for (let index = 0; index < questions.length; index += 1) {
const question = questions[index];
if (typeof question !== "string") continue;
const input = workflowUnknownInput(evidenceId, {
question,
@@ -370,16 +394,15 @@ export const recordWorkflowUnknowns = ({
],
relationships: [],
});
if (!input.ok) return input;
if (!input.ok) return stopped(input.error, index);
const recorded = recordUnknown(input.value);
if (
!recorded.ok &&
!(
recorded.error instanceof UnknownRegistryError &&
recorded.error.reason === "already-exists"
)
if (recorded.ok) created += 1;
else if (
recorded.error instanceof UnknownRegistryError &&
recorded.error.reason === "already-exists"
)
return recorded;
existing += 1;
else return stopped(recorded.error, index);
}
return { ok: true, value: null };
};
+20
View File
@@ -54,6 +54,17 @@ export interface AnalysisCleanupObservation {
readonly resources: readonly string[];
}
/** Publication stopped after a workflow produced its Evidence. Counts describe
* question occurrences, not distinct Unknown identities. */
export interface WorkflowUnknownPublication {
readonly evidence_id: string;
readonly evidence_retention: "retained" | "unverified";
readonly total_questions: number;
readonly created_questions: number;
readonly existing_questions: number;
readonly next_question_index: number;
}
/**
* How long the adapter delivering a failure keeps REA's runtime and provider
* session for its caller. An MCP connection continues: later calls reuse the
@@ -162,6 +173,7 @@ export abstract class AnalysisError extends Error {
readonly userCategory: "cancelled" | undefined = undefined;
readonly executionFailure: string | undefined = undefined;
partialObservation: AnalysisPartialObservation | undefined;
workflowUnknownPublication: WorkflowUnknownPublication | undefined;
get cleanupPrimary(): AnalysisError | undefined {
return this.#cleanupPrimary;
@@ -306,6 +318,14 @@ export abstract class AnalysisError extends Error {
this.partialObservation ??= observation;
return this;
}
/** Preserve the exact committed prefix when automatic question publication stops. */
retainWorkflowUnknownPublication(
publication: WorkflowUnknownPublication,
): this {
this.workflowUnknownPublication ??= Object.freeze({ ...publication });
return this;
}
}
/** Confirmed cleanup reveals the typed primary; unresolved ownership stays visible. */
+53 -1
View File
@@ -33,7 +33,10 @@ import {
} from "./hopperStartupFailure.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { projectAnalysisError } from "./analysisErrorProjection.js";
import {
childFailureDiagnostics,
projectAnalysisError,
} from "./analysisErrorProjection.js";
import {
analysisErrorRemediationAction,
analysisErrorUserMessage,
@@ -48,6 +51,55 @@ import { analysisErrorWithCleanupFailure } from "./analysisErrorCleanup.js";
import type { AnalysisCallerLifetime } from "./analysisErrorBase.js";
import { ProviderCleanupError } from "./providerCleanupError.js";
it("projects nested workflow-publication recovery for the actual caller lifetime", () => {
const child = new UnknownRegistryError(
"limit",
).retainWorkflowUnknownPublication({
evidence_id: `ev_${"a".repeat(64)}`,
evidence_retention: "retained",
total_questions: 3,
created_questions: 1,
existing_questions: 0,
next_question_index: 1,
});
const error = new AnalysisCancelledError(
"trace_feature",
childFailureDiagnostics({ child: { error: child } }),
);
const continuing = projectAnalysisError(error);
const invocation = projectAnalysisError(error, "invocation");
const childOf = (value: typeof continuing) => {
const diagnostics = value.details?.diagnostics;
if (
typeof diagnostics !== "object" ||
diagnostics === null ||
Array.isArray(diagnostics)
)
throw new Error("Expected child diagnostics");
return analysisErrorProjectionSchema.parse(diagnostics.child);
};
expect(
childOf(continuing).details?.workflow_unknown_publication,
).toMatchObject({
evidence_retention: "retained",
created_questions: 1,
next_question_index: 1,
});
expect(childOf(continuing).remediation.action).toContain(
"get_evidence_bundle",
);
expect(
childOf(invocation).details?.workflow_unknown_publication,
).toMatchObject({
evidence_retention: "invocation-only",
created_questions: 1,
next_question_index: 1,
});
expect(childOf(invocation).remediation.action).not.toContain(
"get_evidence_bundle",
);
});
describe("agent recovery metadata", () => {
it("does not mark deterministic input validation as retryable", () => {
const projected = projectAnalysisError(
+23 -3
View File
@@ -128,7 +128,8 @@ const projectionDependsOnCallerLifetime = (error: AnalysisError): boolean => {
if (
node.limit !== undefined ||
node.continuingCallerGuidance !== undefined ||
node.invocationDiagnostics !== undefined
node.invocationDiagnostics !== undefined ||
node.workflowUnknownPublication !== undefined
)
return true;
return false;
@@ -168,8 +169,22 @@ const projectError = (
const underlyingCode = underlyingErrorCode(primary);
const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode;
const primaryDetails = errorDetails(primary);
const publication =
composed.workflowUnknownPublication ?? primary.workflowUnknownPublication;
const details = {
...primaryDetails,
...(publication === undefined
? {}
: {
workflow_unknown_publication: {
...publication,
evidence_retention:
callerLifetime === "invocation" &&
publication.evidence_retention === "retained"
? "invocation-only"
: publication.evidence_retention,
},
}),
...(primary.limit === undefined
? {}
: {
@@ -225,6 +240,11 @@ const projectError = (
? errorRecovery(error)
: cleanupRetryRecovery(cleanupRetry);
const remediation = analysisErrorRemediationAction(error, callerLifetime);
const publicationRemediation =
callerLifetime === "continuing" &&
publication?.evidence_retention === "retained"
? " Use get_evidence_bundle with the Evidence ID in details.workflow_unknown_publication to retrieve the completed result; use list_unknowns to inspect recorded questions. Publication stopped at the reported index; later questions were not checked in this attempt."
: "";
return {
code,
category: analysisErrorCategory(error),
@@ -239,8 +259,8 @@ const projectError = (
remediation: {
action:
cleanupRetry === undefined
? remediation
: `${remediation} ${cleanupRetryRemediation(cleanupRetry)}`,
? `${remediation}${publicationRemediation}`
: `${remediation} ${cleanupRetryRemediation(cleanupRetry)}${publicationRemediation}`,
},
...(recovery === undefined ? {} : { recovery }),
...(Object.keys(details).length === 0 ? {} : { details }),
+30 -12
View File
@@ -292,7 +292,12 @@ const executeEnhancedTool = async (
registration.recordEvidence,
result,
);
await reportOperationEnd(progress, name, result.ok);
const finish = async (
response: CallToolResult,
): Promise<CallToolResult> => {
await reportOperationEnd(progress, name, response.isError !== true);
return response;
};
if (result.ok) {
const evidence = createWorkflowEvidence({
target: registration.activeTarget?.(),
@@ -307,7 +312,9 @@ const executeEnhancedTool = async (
});
const recorded = registration.recordEvidence?.(evidence);
if (recorded !== undefined && !recorded.ok)
return registration.delivery.toCallToolResult(recorded, contract);
return finish(
registration.delivery.toCallToolResult(recorded, contract),
);
if (
name !== "trace_native_ui_action" &&
registration.allowsSnapshotReplay?.(name) === true
@@ -322,7 +329,9 @@ const executeEnhancedTool = async (
const snapshot =
registration.recordWorkflowSnapshot?.(workflowRecord);
if (snapshot !== undefined && !snapshot.ok)
return registration.delivery.toCallToolResult(snapshot, contract);
return finish(
registration.delivery.toCallToolResult(snapshot, contract),
);
}
}
const unknowns = recordWorkflowUnknowns({
@@ -330,9 +339,12 @@ const executeEnhancedTool = async (
result: result.value.result,
evidenceId: evidence.evidence_id,
recordUnknown: registration.recordUnknown,
evidenceRetained: recorded?.ok === true,
});
if (!unknowns.ok)
return registration.delivery.toCallToolResult(unknowns, contract);
return finish(
registration.delivery.toCallToolResult(unknowns, contract),
);
const delivered =
request.detail === "summary"
? summarizeRetainedAnalysis(evidence, {
@@ -341,16 +353,22 @@ const executeEnhancedTool = async (
})
: ok(evidence);
if (!delivered.ok)
return registration.delivery.toCallToolResult(delivered, contract);
return registration.delivery.toEvidenceToolResult(
delivered.value,
contract,
request.detail === "summary"
? registration.recordEvidence?.(delivered.value)
: recorded,
return finish(
registration.delivery.toCallToolResult(delivered, contract),
);
return finish(
registration.delivery.toEvidenceToolResult(
delivered.value,
contract,
request.detail === "summary"
? registration.recordEvidence?.(delivered.value)
: recorded,
),
);
}
return deliverEnhancedFailure(registration, contract, result.error);
return finish(
deliverEnhancedFailure(registration, contract, result.error),
);
},
);
return admitted.ok