mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user