fix(rectification): keep skipped health out of holdout and repair empty exits (BUG-626, BUG-627)
Independent Staging Quality Gate / validate (push) Successful in 12m11s
Independent Staging Quality Gate / publish (push) Successful in 7m54s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 11:59:47 +08:00
co-authored by Cursor
parent 0263658477
commit d96b24c276
16 changed files with 615 additions and 76 deletions
@@ -52,6 +52,8 @@ import {
RECTIFICATION_INSUFFICIENT_CREDITS_NOTICE,
RECTIFICATION_QUESTION_PREPARING_LABEL,
RECTIFICATION_QUESTION_RELOAD_LABEL,
RECTIFICATION_QUESTION_REPAIR_FAILED_COPY,
RECTIFICATION_QUESTION_REPAIR_LIMIT,
RECTIFICATION_QUESTION_RETRY_INTERVAL_MS,
RECTIFICATION_QUESTION_RETRY_LIMIT,
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
@@ -484,6 +486,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const [nextUserActionId, setNextUserActionId] = useState<string | null>(() => caseSnapshotState(initialSnapshot)?.nextUserActionId ?? null);
const [caseSnapshotLoaded, setCaseSnapshotLoaded] = useState(initialSnapshot !== null);
const [questionRetryAttempts, setQuestionRetryAttempts] = useState(0);
const [questionRepairAttempts, setQuestionRepairAttempts] = useState(0);
const [questionRepairing, setQuestionRepairing] = useState(false);
const [openingRequested, setOpeningRequested] = useState(false);
const [acceptingCandidateId, setAcceptingCandidateId] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
@@ -547,6 +551,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setBusy(value);
// A turn starting is a fresh chance for the next question to arrive.
if (value) setQuestionRetryAttempts(0);
if (value) setQuestionRepairAttempts(0);
onPendingChange?.(value);
}, [onPendingChange]);
@@ -649,6 +654,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setCaseStatus(nextCaseStatus);
setNextUserActionId(nextActionId || null);
setCaseSnapshotLoaded(true);
if (nextQuestion || nextChoice || acceptedTime || confirmedTime) {
setQuestionRepairAttempts(0);
}
if (confirmedTime) {
setSavedTime(confirmedTime);
setSavedStatus("confirmed");
@@ -1500,7 +1508,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
));
// The gap between a settled turn and its next question has two visible
// states: `preparing` (one live timeline row, timed refetches) and
// `unavailable` (copy and a reload button) — never bare copy telling the
// `unavailable` (copy and a repair button) — never bare copy telling the
// reader to wait for the server.
const questionGap = rectificationQuestionGapState({
liveQuestionVisible: liveQuestionOnMessages,
@@ -1532,7 +1540,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
});
// Question recovery: the turn already refetched once on completion; while
// the gap is `preparing`, refetch on a timer up to the retry limit, then
// hand the reader a reload button. Attempts reset once a question arrives
// hand the reader a repair button. Attempts reset once a question arrives
// or another turn starts (both in handlers, never in an effect).
useVisibilityAwarePoll({
enabled: questionGap === "preparing",
@@ -1546,9 +1554,40 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
async function refetchQuestion() {
await loadCaseSnapshot();
}
function reloadQuestion() {
setQuestionRetryAttempts(0);
void refetchQuestion();
async function repairQuestion() {
if (questionRepairing || questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT) return;
setQuestionRepairing(true);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/repair-exit`,
{
method: "POST",
headers: { "content-type": "application/json" },
cache: "no-store",
body: JSON.stringify({ sessionId }),
},
);
const payload = await response.json().catch(() => null);
if (response.ok && payload) {
startTransition(() => {
applyCaseSnapshot(payload);
});
const recovered = Boolean(
currentQuestionFromSnapshot(payload.current_question)
|| parseRectificationChoiceCard(payload.choice_card)
|| payload.case?.accepted_time
|| payload.case?.confirmed_time
);
if (!recovered) setQuestionRepairAttempts((current) => current + 1);
} else {
await loadCaseSnapshot();
setQuestionRepairAttempts((current) => current + 1);
}
} catch {
setQuestionRepairAttempts((current) => current + 1);
} finally {
setQuestionRepairing(false);
}
}
// A Case with no turns and no automatic first turn (the server never starts
@@ -1768,10 +1807,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
)}
{questionGap === "unavailable" && (
<div className="rectification-message-wrap rectification-message-entry rectification-question-gap" role="status">
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={reloadQuestion}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
{questionRepairAttempts >= RECTIFICATION_QUESTION_REPAIR_LIMIT ? (
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_REPAIR_FAILED_COPY}</p>
) : (
<>
<p className="rectification-question-gap__copy">{RECTIFICATION_QUESTION_UNAVAILABLE_COPY}</p>
<Button type="button" variant="outline" onClick={() => void repairQuestion()} disabled={questionRepairing}>
{RECTIFICATION_QUESTION_RELOAD_LABEL}
</Button>
</>
)}
</div>
)}
{questionGap === "verified_idle" && verifiedIdleCopy && !showSelectionCards && (