fix(desktop): ignore replays of an already-answered prompt id

Round-2 review of #6432 found the idle-rejection backstop was necessary
but not sufficient: it only reconciles when a fetched snapshot is
rejected as stale, but a delayed replay of an already-answered prompt
can also arrive with no snapshot rejection ever happening (idle applied
before the replay lands, or a fresh running=true/pendingPrompt=false
snapshot that never touches approval/ask). Either way turn_done's
keepPlanApproval then resurrects the zombie approval permanently.

Track the id of the most recently user-resolved approval/ask
(resolvedPromptId, set by clearApproval/clearAsk) and ignore any
approval_request/ask_request replay carrying that same id outright,
instead of re-arming it and hoping a later snapshot disproves it.
This commit is contained in:
SivanCola committed 2026-07-14 09:09:18 +08:00
1 parent 6e4596cc04
commit 9ed15099b3
2 files changed
+50 -20

No files matched your search

@@ -88,28 +88,46 @@ const replayed = reducer(withApproval, { type: "event", e: planApprovalEvent });
eq(replayed.promptArrivedAt, withApproval.promptArrivedAt, "same-id replay keeps the original arrival time");
eq(replayed.promptArrivedId, "plan-1", "same-id replay keeps the anchor id");
// #6432 reverse race: user answers, a delayed replay of the answered prompt
// re-arms it, then the authoritative idle snapshot (fetched after the answer,
// before the delayed replay) must still clear the resolved prompt, and a later
// turn_done must not resurrect it.
// #6432 reverse race: user answers, a delayed replay of the SAME answered
// prompt id must not re-arm it at all — no downstream snapshot or turn_done
// is guaranteed to ever get a chance to disprove it (round 2 review: an idle
// snapshot dispatched before the replay has nothing to reject, and a fresh
// running=true/pendingPrompt=false snapshot never touches approval/ask).
{
const armed = reducer({ ...initialState }, { type: "event", e: planApprovalEvent });
const originalArrival = armed.promptArrivedAt!;
const answeredEarly = reducer(armed, { type: "clearApproval" });
const authoritativeIdleAt = promptEventClock();
const zombie = reducer(answeredEarly, { type: "event", e: planApprovalEvent });
eq(zombie.promptArrivedAt, originalArrival, "delayed replay of an answered prompt does not advance the anchor");
ok(authoritativeIdleAt > originalArrival, "authoritative idle snapshot is newer than the original arrival");
const reconciled = reducer(zombie, { ...idleStatus, snapshotAt: authoritativeIdleAt });
eq(reconciled.approval, undefined, "authoritative idle after the answer clears the resurrected approval");
eq(reconciled.running, false, "authoritative idle after the answer ends the blocked state");
const afterTurnDone = reducer(reconciled, { type: "event", e: { kind: "turn_done" } as WireEvent });
eq(afterTurnDone.approval, undefined, "turn_done cannot resurrect an already-reconciled plan approval");
// Ordering variant: turn_done arrives BEFORE the authoritative idle snapshot.
const keptByTurnDone = reducer(zombie, { type: "event", e: { kind: "turn_done" } as WireEvent });
eq(keptByTurnDone.approval?.id, "plan-1", "turn_done still keeps a plan approval it cannot yet disprove");
const clearedLate = reducer(keptByTurnDone, { ...idleStatus, snapshotAt: promptEventClock() });
eq(clearedLate.approval, undefined, "a later authoritative idle clears the zombie kept by turn_done");
eq(answeredEarly.resolvedPromptId, "plan-1", "answering records the resolved prompt id");
const replayed = reducer(answeredEarly, { type: "event", e: planApprovalEvent });
eq(replayed.approval, undefined, "a same-id replay of an answered prompt is ignored, not re-armed");
eq(replayed.running, answeredEarly.running, "an ignored replay leaves running/turnActive exactly as the answer left them");
eq(replayed.promptArrivedAt, originalArrival, "an ignored replay leaves the original anchor untouched");
const afterTurnDone = reducer(replayed, { type: "event", e: { kind: "turn_done" } as WireEvent });
eq(afterTurnDone.approval, undefined, "turn_done cannot resurrect a replay that was never re-armed");
// Round 2, sequence 1: an idle snapshot dispatched between the answer and
// the delayed replay has nothing to reject (no live approval to compare
// against) — the replay must still be suppressed when it lands after.
const idleBetween = reducer(answeredEarly, { ...idleStatus, snapshotAt: promptEventClock() });
const replayAfterIdle = reducer(idleBetween, { type: "event", e: planApprovalEvent });
eq(replayAfterIdle.approval, undefined, "a replay landing after an already-applied idle snapshot is still ignored");
const afterTurnDone2 = reducer(replayAfterIdle, { type: "event", e: { kind: "turn_done" } as WireEvent });
eq(afterTurnDone2.approval, undefined, "turn_done stays clear after the idle-then-replay ordering");
// Round 2, sequence 2: a fresh running=true/pendingPrompt=false snapshot
// (backend genuinely executing the approved plan, no prompt pending) must
// not be able to inherit a zombie approval, because there is none to inherit.
const busySnapshot = reducer(answeredEarly, {
type: "backend_status",
running: true,
pendingPrompt: false,
backgroundJobs: 0,
cancelRequested: false,
cancellable: true,
snapshotAt: promptEventClock(),
});
const replayDuringBusy = reducer(busySnapshot, { type: "event", e: planApprovalEvent });
eq(replayDuringBusy.approval, undefined, "a replay during a genuinely busy, non-pending turn is still ignored");
}
// A genuinely new prompt (different id) after an answer re-anchors, so its own
+14 -2
View File
@@ -152,6 +152,13 @@ interface State {
// same id keeps the original arrival time; only a genuinely new prompt id
// (backend ids are monotonic within a controller) re-anchors it.
promptArrivedId?: string;
// Id of the most recently user-resolved approval/ask (explicit answer,
// cancel-through-mode-switch, etc). A replay carrying this same id is a
// stale re-delivery of an already-answered prompt, not a new one — arming
// it would resurrect a zombie no downstream snapshot may ever get a chance
// to reject (#6432 round 2: idle-applied-before-replay, and
// running=true/pendingPrompt=false snapshots that never clear approval/ask).
resolvedPromptId?: string;
turnTokens: number;
turnTotalTokens: number;
turnCost: number;
@@ -951,6 +958,10 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `s${s.seq}`, level: "info", text: `${STEER_NOTICE_PREFIX}${e.text ?? ""}` }] };
case "approval_request": {
if (s.cancelRequested) return s;
// A delayed re-delivery of a prompt the user already answered locally
// (clearApproval) must not resurrect it — no downstream snapshot is
// guaranteed to ever reject it again (#6432 round 2).
if (e.approval?.id !== undefined && e.approval.id === s.resolvedPromptId) return s;
return beginPromptWait({
...s,
approval: e.approval,
@@ -967,6 +978,7 @@ function applyEvent(s: State, e: WireEvent): State {
}
case "ask_request": {
if (s.cancelRequested) return s;
if (e.ask?.id !== undefined && e.ask.id === s.resolvedPromptId) return s;
return beginPromptWait({
...s,
ask: e.ask,
@@ -1264,11 +1276,11 @@ export function reducer(s: State, a: Action): State {
return { ...s, items: mergeHistoryCheckpointTurns(s.items, a.turns, s.historyStartTurn) };
case "local_notice": return { ...s, running: false, turnActive: false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: a.level, text: a.text }] };
case "clearApproval": {
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask) };
const next = { ...s, approval: undefined, pendingPrompt: Boolean(s.ask), resolvedPromptId: s.approval?.id ?? s.resolvedPromptId };
return endPromptWaitIfIdle(next);
}
case "clearAsk": {
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval) };
const next = { ...s, ask: undefined, pendingPrompt: Boolean(s.approval), resolvedPromptId: s.ask?.id ?? s.resolvedPromptId };
return endPromptWaitIfIdle(next);
}
case "reset": return { ...initialState, meta: s.meta, context: { used: 0, window: s.context.window, sessionTokens: 0, compactRatio: s.context.compactRatio }, balance: s.balance, effort: s.effort, jobs: s.jobs, hydrating: s.hydrating, hydrateReason: s.hydrateReason, hydrateError: s.hydrateError, hydrateHistoryLoaded: s.hydrateHistoryLoaded, hydratePlaceholderItems: s.hydratePlaceholderItems, backendActivationPending: s.backendActivationPending, sessionGen: s.sessionGen + 1 };