fix(rectification): guarantee nonterminal turn exits
Independent Staging Quality Gate / validate (push) Successful in 13m25s
Independent Staging Quality Gate / publish (push) Successful in 9m26s

This commit is contained in:
Jesse_Chen
2026-09-01 13:35:51 +08:00
parent 15877069fc
commit e404b6f42b
13 changed files with 966 additions and 125 deletions
@@ -13,7 +13,6 @@ import { decideFromDossier, rectificationFollowupCatalog } from "@/lib/rectifica
import {
applyRectificationChoice,
applyCollectFocusDenial,
ensureNonTerminalTurnExit,
persistNextInterviewIfIdle,
} from "@/lib/rectification-agentic/v9/answer-choice";
import { mapRectificationRpcError } from "@/lib/rectification-agentic/v9/case-service";
@@ -41,6 +40,12 @@ import {
import { persistServerOwnedFocus, openQuestionFromPersistedFocus, isCollectFocusSchema, isRenderableChoiceOpenQuestion } from "@/lib/rectification-agentic/v9/server-focus";
import { buildMethodFollowupPlan } from "@/lib/rectification-agentic/v9/method-followup";
import { isNonConvergingRangeOffer, nonConvergingRangeNarration } from "@/lib/rectification-agentic/core/rectification-decision";
import {
awaitTurnExitBeforeResponse,
finalizeSuccessfulTurnExit,
RECTIFICATION_ACTION_EXECUTION,
type RectificationRouteAction,
} from "@/lib/rectification-agentic/v9/turn-exit";
export const runtime = "nodejs";
export const maxDuration = 240;
@@ -86,6 +91,9 @@ const agentRequestSchema = z.object({
clientActionId: z.string().uuid().optional(),
}).strict();
type ParsedRectificationAction = z.infer<typeof agentRequestSchema>["action"];
const rectificationActionExecution: Record<ParsedRectificationAction, (typeof RECTIFICATION_ACTION_EXECUTION)[RectificationRouteAction]> = RECTIFICATION_ACTION_EXECUTION;
function actionToBudget(action: "opening" | "message" | "read_only"): RectificationAgentAction {
if (action === "opening") return "opening";
if (action === "read_only") return "read_only";
@@ -170,7 +178,8 @@ export async function POST(request: Request) {
const userId = user.id;
const { caseId, sessionId, requestId, action } = parsed.data;
const isStructuredChoice = action === "answer_choice" || action === "stop_and_review";
const execution = rectificationActionExecution[action];
const isStructuredChoice = execution === "immediate";
// Feature selector: the V9 runtime is DB-driven. When the flag is not
// published/enabled, no new runs are served (legacy stays read-only).
@@ -246,8 +255,15 @@ export async function POST(request: Request) {
);
}
if (isStructuredChoice) {
const actionId = parsed.data.actionId;
const selectedModel = isStructuredChoice
? null
: await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
const immediateResponse = await (async (): Promise<Response | null> => {
if (isStructuredChoice) {
const actionId = parsed.data.actionId;
const focusId = parsed.data.focusId;
const expectedRevision = parsed.data.expectedRevision;
if (!actionId || !focusId || expectedRevision === undefined) {
@@ -309,11 +325,8 @@ export async function POST(request: Request) {
}
}
const selectedModel = await resolveSessionLanguageModel(
chatSession.model_id,
chatSession.model_config_version,
);
if (!selectedModel) {
const resolvedModel = selectedModel;
if (!resolvedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
@@ -328,7 +341,7 @@ export async function POST(request: Request) {
if (focus && choice) {
let classified = null;
try {
classified = await classifyRectificationTurnIntent(selectedModel, {
classified = await classifyRectificationTurnIntent(resolvedModel, {
focus,
userMessage: parsed.data.message ?? "",
caseStatus,
@@ -537,6 +550,34 @@ export async function POST(request: Request) {
}
}
return null;
})();
if (immediateResponse) {
if (immediateResponse.status !== 200) return immediateResponse;
const response = await awaitTurnExitBeforeResponse(
immediateResponse,
() => finalizeSuccessfulTurnExit({
accounting: accounting as never,
userId,
caseId,
action,
}),
);
return response;
}
if (action === "answer_choice" || action === "stop_and_review") {
return NextResponse.json(
{ error: "选择题处理失败", message: "请稍后重试。" },
{ status: 500 },
);
}
if (!selectedModel) {
return NextResponse.json(
{ error: "模型暂不可用", message: "请选择其他模型后重新发送,本次不会扣除点数。" },
{ status: 409 },
);
}
const requestTime = new Date();
const chinaTime = new Date(requestTime.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -673,30 +714,14 @@ export async function POST(request: Request) {
if (!result.ok) {
send({ type: "error", message: "生时校正暂时不可用,请稍后重试。" });
} else {
if (action === "message" || action === "opening") {
try {
await persistNextInterviewIfIdle({
accounting: accounting as never,
userId,
caseId,
});
} catch (error) {
console.warn(
`[rectification-v9] persist next interview after turn failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
try {
await ensureNonTerminalTurnExit({
accounting: accounting as never,
userId,
caseId,
});
} catch (error) {
console.warn(
`[rectification-v9] nonterminal turn exit repair failed case=${caseId} reason=${error instanceof Error ? error.name : "Unknown"}`,
);
}
}
// Shared gate owns persistNextInterviewIfIdle then ensureNonTerminalTurnExit;
// The shared gate replaces the old message/opening-only cleanup.
await finalizeSuccessfulTurnExit({
accounting: accounting as never,
userId,
caseId,
action,
});
send({ type: "done", emitted: true });
}
} catch (error) {