Repository navigation
fix(server): a failed rollback shows an error instead of hanging - #13887
Conversation
The provider-thread.rollback effect retried five times and then failed silently, so clients waited for runs to reach rolled_back until they timed out. On the last failed attempt the worker now dispatches an internal checkpoint.rollback.fail command, which sets thread.rollbackFailure with a user-facing reason through the normal decider and projector. The next checkpoint.rollback clears it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
| }), | ||
| /** Server-only: records that the provider rollback for `requestId` failed for good. */ | ||
| Schema.Struct({ | ||
| type: Schema.Literal("checkpoint.rollback.fail"), |
There was a problem hiding this comment.
🟡 Medium src/orchestrationV2.ts:2607
A late checkpoint.rollback.fail from an older rollback overwrites the thread's rollbackFailure after a newer rollback has already cleared it, so a successful newer rollback still leaves a stale failure visible. This command carries only the old requestId, and its handler records the failure without verifying that it is still the current rollback; add a current-request/generation check and ignore superseded failures.
Also found in 3 other location(s)
apps/server/src/orchestration-v2/EffectWorker.ts:362
The failure marker is dispatched after the rollback effect has already failed, without verifying that its rollback request is still the latest one. If a user starts a new rollback in the interval before this dispatch obtains the thread lock, that new command sees no existing marker and cannot clear it; the old attempt then writes its
rollbackFailureafter the newer rollback (even if the latter succeeds). This leaves a stale failure visible on the thread and contradicts the intended clearing behavior.
apps/server/src/orchestration-v2/Orchestrator.ts:7939
dispatchCheckpointRollbackFailunconditionally overwritesrollbackFailureforcommand.requestId. A prior rollback can be waiting for its retry while a later rollback is accepted and clears the marker; effects are only serialized while one isrunning, so the later rollback can settle before the older retry exhausts. When that older effect then fails, this line restores its stale failure marker, leaving the thread reporting a failed rollback after the newer request completed.
apps/server/src/orchestration-v2/Orchestrator.ts:8929
Dispatching
checkpoint.rollback.failunconditionally records the failure even when its original rollback has been superseded. For example, rollback A can still be retrying, rollback B can be accepted while no marker exists, and then A's final failed retry dispatches this command afterward; the handler invoked here writes A'srollbackFailureover B's state. The stale error remains until yet another rollback and can be displayed as a failure after the newer rollback has succeeded.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/contracts/src/orchestrationV2.ts around line 2607:
A late `checkpoint.rollback.fail` from an older rollback overwrites the thread's `rollbackFailure` after a newer rollback has already cleared it, so a successful newer rollback still leaves a stale failure visible. This command carries only the old `requestId`, and its handler records the failure without verifying that it is still the current rollback; add a current-request/generation check and ignore superseded failures.
Also found in 3 other location(s):
- apps/server/src/orchestration-v2/EffectWorker.ts:362 -- The failure marker is dispatched after the rollback effect has already failed, without verifying that its rollback request is still the latest one. If a user starts a new rollback in the interval before this dispatch obtains the thread lock, that new command sees no existing marker and cannot clear it; the old attempt then writes its `rollbackFailure` after the newer rollback (even if the latter succeeds). This leaves a stale failure visible on the thread and contradicts the intended clearing behavior.
- apps/server/src/orchestration-v2/Orchestrator.ts:7939 -- `dispatchCheckpointRollbackFail` unconditionally overwrites `rollbackFailure` for `command.requestId`. A prior rollback can be waiting for its retry while a later rollback is accepted and clears the marker; effects are only serialized while one is `running`, so the later rollback can settle before the older retry exhausts. When that older effect then fails, this line restores its stale failure marker, leaving the thread reporting a failed rollback after the newer request completed.
- apps/server/src/orchestration-v2/Orchestrator.ts:8929 -- Dispatching `checkpoint.rollback.fail` unconditionally records the failure even when its original rollback has been superseded. For example, rollback A can still be retrying, rollback B can be accepted while no marker exists, and then A's final failed retry dispatches this command afterward; the handler invoked here writes A's `rollbackFailure` over B's state. The stale error remains until yet another rollback and can be displayed as a failure after the newer rollback has succeeded.
| }), | ||
| /** Server-only: records that the provider rollback for `requestId` failed for good. */ | ||
| Schema.Struct({ | ||
| type: Schema.Literal("checkpoint.rollback.fail"), |
There was a problem hiding this comment.
🟡 Medium src/orchestrationV2.ts:2607
Any client with the normal operate scope can submit checkpoint.rollback.fail through orchestration.dispatchCommand and persist a fabricated rollbackFailure for an arbitrary requestId and message, including an active rollback, causing the UI to reject a real rollback. The Server-only comment does not enforce that restriction because this member is part of the publicly accepted OrchestrationV2Command union; remove it from the public schema or enforce server-only authorization before dispatch.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/contracts/src/orchestrationV2.ts around line 2607:
Any client with the normal operate scope can submit `checkpoint.rollback.fail` through `orchestration.dispatchCommand` and persist a fabricated `rollbackFailure` for an arbitrary `requestId` and message, including an active rollback, causing the UI to reject a real rollback. The `Server-only` comment does not enforce that restriction because this member is part of the publicly accepted `OrchestrationV2Command` union; remove it from the public schema or enforce server-only authorization before dispatch.
| } from "./EffectOutbox.ts"; | ||
| import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; | ||
| import { | ||
| CheckpointRollbackServiceV2, |
There was a problem hiding this comment.
This changed import consumes CheckpointRollbackServiceV2 as an Effect service, so import the local module as a namespace and access its public members through that namespace (including the message constant). This preserves the service-module boundary convention. The fix also updates the service references below, so it is not confined to this diff hunk.
Posted via Macroscope — Effect Service Conventions
| commandId: CommandId.make(`${effect.commandId}:rollback-failed`), | ||
| threadId: effect.threadId, | ||
| requestId: effect.commandId, | ||
| message: Option.match(Cause.findErrorOption(cause), { |
There was a problem hiding this comment.
Persisting Cause.findErrorOption(cause).message exposes arbitrary lower-level failure text to clients; it can contain provider payloads, URLs, or other sensitive details. Suggest persisting the bounded, safe rollback message instead and keeping the underlying cause in server-side diagnostics.
| message: Option.match(Cause.findErrorOption(cause), { | |
| message: ROLLBACK_FAILED_MESSAGE, |
Posted via Macroscope — Effect Service Conventions
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This production rollback fix adds a new failure-recording command and changes existing server and UI behavior. The command is publicly dispatchable despite being labeled server-only, late failures can leave stale rollback state, and raw provider errors may be exposed to clients. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
…ing row waitForRevertedMessage now takes the rollback's command id and rejects with the server's reason as soon as thread.rollbackFailure names that request, instead of waiting up to 2 minutes. A rewind no longer counts as agent work, so the timeline stops showing Thinking; the composer keeps its "Rewinding conversation" disabled state. The rollback failure schema is inlined so the contract adds no new export. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
9fcc9a4
into
t3code/codex-turn-mapping
A provider rollback that fails ("Edit from here" / revert) left the web UI on "Thinking" for 2 minutes, then showed a generic timeout. The
provider-thread.rollbackeffect retried five times and failed silently. The client had nothing to wait on except runs reachingrolled_back.What changed
Server
provider-thread.rollback,EffectWorkerdispatches an internalcheckpoint.rollback.failcommand. Its decider emitsthread.metadata-updatedwith a new optionalthread.rollbackFailure: { requestId, message }. The marker goes through the normal command → event → projector path; the worker writes no projection state itself.checkpoint.rollbackclears the marker, so a stale failure never rejects a later rollback.OrchestrationV2AppThread(the JSON mirror derives from it, and there is no new export). Old clients and stored events still decode.unexpected-failurerollback message is now user-facing, not an internal id dump.Web
onRevertToTurnCountallocates the rollback's command id and passes it towaitForRevertedMessage. That function rejects with the server's reason as soon asthread.rollbackFailurenames that request. The existingsetThreadErrorpath shows it.isWorking, so the timeline stops showing the "Thinking" row. The composer keeps its existing disabled state ("Rewinding conversation",inertoverlay). Compaction still stays disabled during a rewind. Nocomponents/uichanges.Mobile: no change. It has no rewind flow;
apps/mobilenever dispatchescheckpoint.rollback.Verification
runtimeLayer.test.tscase. The real worker drains a rollback whose provider session cannot open, with the clock advanced through the retry backoff. It asserts that the effect endsfailed, thatthread.rollbackFailureis projected with the request id and message, and that the next rollback clears it.expected undefined to deeply equal {…}).waitForRevertedMessagelogic tests (no rendering). A projected failure for the request rejects with its message; a failure recorded for an earlier request is ignored.vp test run, all passing:runtimeLayer,EffectWorker,CheckpointRollbackService: 68/68orchestrationV2: 27/27ChatView.logic,MessagesTimeline.logic,composerDraftStore: 405/405tsc --noEmitis clean forpackages/contracts,packages/client-runtime,apps/server,apps/web, andapps/mobile.vp linton the touched files reports only pre-existing warnings. knip's unused-export count is unchanged (785).Model: Claude Opus 5.5 (Claude Code)
🤖 Generated with Claude Code