plans/better-version-recovery.md
Implementation plan created 2026-08-18
Fix the Stop generation -> Undo workflow so Dyad durably remembers that the target turn was cancelled, preserves its partial working-tree changes in a recoverable checkpoint commit, and completes Undo without losing work. Also narrow restore-recovery-required to genuinely ambiguous Git states and add validated Use current version and Save changes & use current version paths for stale recovery checkpoints.
Ship all three changes in one PR because they form one recovery contract: prevent the common false-positive, classify failures correctly, and provide an escape hatch for checkpoints that still cannot be reconciled automatically.
The reproduced failure has this sequence:
recovery = "terminal"; it does not preserve whether the turn completed, was cancelled, or errored.revertVersionHandler with currentChatMessageId; the fork action calls restoreToMessageHandler. Neither path sees a currently active stream after Stop has settled.gitStageToRevert therefore throws Cannot revert: working tree has uncommitted changes.nextStep = "checkout-branch", so the command adapter escalates the ordinary conflict to restore-recovery-required based only on the checkpoint label.The Git dirty-tree refusal is a valid data-loss guard. The bugs are losing the cancelled-turn provenance, classifying a pre-destructive refusal as ambiguous recovery, and providing no supported acknowledgement path for a genuinely stale checkpoint.
restore-recovery-required when no destructive reset may have started and Git remains at the pre-restore branch and HEAD.When the target user message belongs to a durably cancelled turn, Undo may commit the whole dirty tree before restoring. This matches the existing active-cancellation behavior and is lossless: unrelated manual edits may be included in the checkpoint, but nothing is silently overwritten. The success/warning copy must disclose that partial changes were saved.
The checkpoint is an ordinary commit on the app's reachable branch history, so it appears in Version History and supports the same preview and restore actions as every other version. Use the canonical commit message [Interrupted] Saved partial changes before restoring to an earlier version. The [Interrupted] prefix is the user-visible label; do not add a second checkpoint model or a special Version History row type in this PR.
Extend the existing chat_turn_intents record rather than creating a second cancellation registry. The user message already carries chatTurnIntentId, and chat_turn_intents.accepted_message_id points back to that message.
Add a nullable terminal outcome with values:
completedcancellederrorednull means a legacy or non-terminal row whose outcome is unknown. Unknown must remain conservative; it must not be treated as cancelled merely because the tree is dirty.
Persist the outcome in the same SQLite transaction that marks recovery = "terminal" and removes the queue entry. Update the in-memory IntentRecord at the same linearization point so live and restarted behavior agree.
For compatibility with turns created before the migration, allow a narrowly scoped fallback only when the target user message is immediately followed by an assistant message recognized by isCancelledResponseContent, with no successful commitHash. Keep this fallback in one helper and test it; do not infer cancellation from an absent commit alone.
A dirty tree is an ordinary Git conflict unless a destructive restore step may already have crossed its checkpoint. Move the non-preserving dirty-tree preflight before branch checkout and before any destructive checkpoint.
The failure disposition must be based on both the last effect boundary and a repository probe, not only restoreProgress.nextStep:
preparing or preflight failure: ordinary RESTORE_FAILED.checkout-branch, with the repository still at the recorded pre-restore branch and HEAD and no destructive step started: ordinary RESTORE_FAILED, regardless of dirtiness.checkout-branch, with branch or HEAD divergence: recovery required.preserve-dirty-tree, hard-reset, soft-reset, or commit: recovery required unless a stronger, explicitly tested postcondition proves a terminal state.chat-mutation: recovery required because Git may be complete while the chat fork is not.completed: reconcile against the recorded completed HEAD as today.Skip git checkout entirely when revertRef is already the current branch. Only write a checkout-branch checkpoint when a checkout will actually execute.
Classify the failure while the restore still owns its repository operation claim. Do not release the coordinator lock and then race a new chat turn while probing Git.
Do not add a cosmetic Dismiss action. Add a state-machine intent that means: “validate the repository as it exists now, abandon the unfinished restore checkpoint, and resume from current HEAD.”
The action must not modify Git. Before accepting, main must verify under a coordinated repository read that:
On success, return authoritative appId, branch, accepted HEAD, and optional saved-version ID; transition to closed; remove the checkpoint through the existing persistence observer; reset historical-preview presentation; refresh repository, version, file, and chat data; dismiss the recovery toast; and restore normal Version History capabilities. On failure, remain in restore-recovery-required and replace the description with an actionable DyadErrorKind.Conflict or Precondition message.
A dirty tree is recoverable without asking the user to understand Git. If read-only validation finds an otherwise healthy repository whose only blocker is uncommitted changes, keep recovery active and change the primary action to Save changes & use current version.
This is a separate, explicitly mutating action. Under a coordinated repository write claim it must:
[Recovery] Saved current changes before continuing Version History so this distinct, user-initiated recovery save is also recognizable as an ordinary version;closed and remove the stale restore checkpoint.If staging, committing, or final validation fails, leave restore-recovery-required in place and show the specific error. Never reset, discard, force-checkout, or clear recovery on a partial failure. If Dyad crashes after the commit but before acknowledgement, restart reconciliation sees a clean repository and the user can safely choose Use current version.
Detached HEAD, unresolved index entries, missing repositories, and active merge/rebase/cherry-pick/revert/bisect operations do not offer the save action. Dyad does not attempt to repair, continue, abort, reset, or back up these states in this feature. The toast explains the detected blocker in plain language and offers only the read-only Check again action after the user resolves it outside Dyad.
[Interrupted] Saved partial changes before restoring to an earlier version and then completes the requested Undo or restore/fork flow.No additional confirmation is required because the operation is lossless and the user already confirmed Undo. If checkpoint creation fails, leave the tree untouched and show the ordinary Git conflict; do not continue to reset.
If the target turn is completed/unknown and the tree is dirty, Undo fails with the existing conflict message. Version History stays usable because the failure occurred before any destructive restore boundary.
Show the existing persistent toast with one primary action:
Version restore needs attention
Dyad could not verify an earlier restore. If this project is clean, you can continue from its current version.
Use current version
While validation runs, update the toast description to “Checking the current repository…” and suppress duplicate clicks. If validation fails, retain the toast and render the next action from the authoritative blocker classification.
If the only validation failure is a dirty working tree, do not tell the user to open a terminal, commit, or stash. Update the same toast in place:
Your current changes need to be saved
Dyad found changes that are not part of a saved version. Save them as the current version to continue using Version History.
Save changes & use current version
While saving, show “Saving the current version…” and disable duplicate activation. On success, dismiss the toast and restore Version History. On failure, keep the toast open with the concrete error and a retry action.
Acceptance returns the user to the live project without navigating or reopening Version History automatically. It must clear any historical version selection and diff, refresh the current branch/HEAD and Version History, refresh file/change data, and re-read chats/messages so the renderer reflects whatever durable chat mutation did or did not complete before recovery. Keep the currently selected chat if it still exists; otherwise select the app's latest surviving chat. Acceptance itself never forks, deletes, trims, or rewrites chat.
Show passive confirmation with no action button:
For read-only acceptance:
Version History is ready
Continuing from the current code version.
For checkpoint-and-accept:
Current changes saved
Your changes were saved as[Recovery] Saved current changes before continuing Version History. Version History is ready.
Both success toasts use the normal finite duration. Do not include Open Version History, View saved version, or any other action, and do not force navigation. The [Recovery] commit is available the next time the user opens Version History.
For unresolved file conflicts, use blocker-specific copy:
Version History is unavailable
This project has unresolved file conflicts. Resolve them outside Dyad, then check again.
Check again
For an in-progress Git operation, name the operation when known:
Version History is unavailable
A Git rebase is still in progress. Finish or cancel it outside Dyad, then check again.
Check again
Use the same pattern for merge, cherry-pick, revert, and bisect. Check again only reruns the read-only repository-health probe; it never continues, aborts, resets, commits, or otherwise changes Git. If the blocker remains, keep the toast and recovery checkpoint unchanged. Do not add a recovery panel for these states in this PR.
The close affordance may hide the toast for the current renderer session, but it must not clear recovery or unlock mutations. While recovery remains unresolved, opening the Version History pane shows an inline blocked state instead of an empty list or disabled controls:
Version History is temporarily unavailable
Resolve the version recovery notice to continue.
The pane must not open automatically solely because recovery was detected. Preserve whether it was already open; allow it to close normally; and show the blocked state whenever the user opens it during recovery. Opening the pane should also resurface the persistent recovery toast if it was dismissed. The inline state is explanatory only and contains no duplicate repair actions.
Affected areas:
src/db/schema.tsdrizzle/ migration and migration metadatasrc/chat_stream/persistence.tssrc/chat_stream/definition.tsChanges:
terminal_outcome to chat_turn_intents with the three values above.IntentRecord and persisted queue hydration to carry it.STREAM_ENDED + wasCancelled -> cancelledSTREAM_ENDED -> completedSTREAM_ERRORED -> erroredterminalOutcome, recovery = "terminal", queue removal, and pause state atomically.No new standalone IPC endpoint is needed.
Affected areas:
src/ipc/handlers/version_handlers.tssrc/shared/chatCancellation.ts only if the legacy fallback is centralized thereAdd a helper such as getRestoreTargetTurnOutcome(chat, messageId) that:
chatTurnIntentId and matching chat_turn_intents row;cancelled, completed, errored, or unknown;Set:
preserveDirtyTree =
didCancelActor ||
didCancelTransport ||
latestTargetTurnOutcome === "cancelled";
For footer Undo, currentChatMessageId identifies the same target user turn; pass preserveDirtyTree = true only when that fresh durable outcome resolves to cancelled. This ensures Stop -> footer Undo and Stop -> Restore code & fork chat share the same preservation contract.
Do not trust the phase-1 result across cancellation awaits. The phase-3 query under chat-content and repository ownership is authoritative.
Continue using the existing whole-tree checkpoint behavior because it preserves all user-visible changes. Update logging to identify why preservation was enabled (cancelled-now versus previously-cancelled-turn) without logging file contents.
Keep the interrupted checkpoint on reachable branch history. Do not store it only in the reflog or behind an internal-only reference. Because listVersions is backed by the branch's Git log, the canonical commit automatically becomes a normal, numbered, selectable, previewable, and restorable Version History entry without requiring a messages.commitHash association or dedicated versions metadata row.
Affected areas:
src/ipc/handlers/version_handlers.tssrc/ipc/utils/git_utils.tssrc/ipc/services/version_preview_service.tssrc/ipc/services/version_preview_definition.tssrc/version_preview/state.tsChanges:
checkpointGitStep("checkout-branch") whenever preserveDirtyTree is false. Reuse the same user-visible-path rules as gitStageToRevert; do not create two subtly different definitions of “dirty.”DyadError classification and original user-facing messages. A dirty tree remains DyadErrorKind.Conflict and should be filtered from exception telemetry.RESTORE_FAILED or RESTORE_RECOVERY_REQUIRED from that disposition. Remove the current heuristic that treats every progress value other than preparing/completed as recovery-required.The structured disposition is internal to the main-process bridge; it should not weaken the typed renderer transport or expose raw errors over IPC.
Affected areas:
src/version_preview/state.tssrc/version_preview/transition.tssrc/version_preview/projection.tssrc/version_preview/transport.tssrc/ipc/services/version_preview_definition.tssrc/ipc/services/version_preview_service.tssrc/ipc/services/version_preview_persistence.tssrc/version_preview/VersionPreviewProvider.tsxsrc/components/chat/VersionPane.tsxState-machine additions:
ACCEPT_CURRENT_REPOSITORY with a stable operationId.CHECKPOINT_AND_ACCEPT_CURRENT_REPOSITORY with a stable operationId, exposed only after validation reports a dirty-tree-only blocker.validating-current-repository, retaining the prior recovery session/error.checkpointing-current-repository, also retaining the prior recovery session/error.validate-current-repository.checkpoint-and-validate-current-repository.CURRENT_REPOSITORY_ACCEPTED carrying authoritative appId, branch, acceptedHead, and optional savedVersionId; CURRENT_REPOSITORY_DIRTY; and CURRENT_REPOSITORY_REJECTED.canAcceptCurrentRepository, true only in restore-recovery-required while transport is ready.canCheckpointAndAcceptCurrentRepository, true only when the latest authoritative validation classified the sole blocker as a dirty working tree.Transition behavior:
restore-recovery-required + ACCEPT_CURRENT_REPOSITORY -> validating state + one command.canCheckpointAndAcceptCurrentRepository.restore-recovery-required + CHECKPOINT_AND_ACCEPT_CURRENT_REPOSITORY -> checkpointing state + one command, only for that assessment.canAcceptCurrentRepository still available as Check again; redispatch uses a fresh operationId and reruns only read-only validation.closed, clearing the recovery session's selected version/diff presentation.OPEN shows the blocked pane and resurfaces the toast, while CLOSE hides the pane without clearing recovery. Do not persist this visibility bit; after restart it defaults closed until the user opens Version History.Persistence behavior:
restore-recovery-required snapshot so the safety latch survives restart.closed removes the checkpoint through the existing adapter.Command behavior:
readAppResource("app-path") and a repository read claim through appOperationCoordinator.inspectRepositoryHealth probe returning structured branch, HEAD, cleanliness, unmerged-index, and operation-in-progress facts; use it for restore classification, reconciliation, and acceptance rather than duplicating Git sentinel logic.Renderer behavior:
useVersionPreview/the existing remote intent path, not a direct IPC client.CHAT_TURN_TERMINAL_OUTCOMES const tuple, derive the shared schema/string union from it, and pass the same tuple to Drizzle's text-enum declaration. The generated migration is the database representation; do not maintain a second handwritten application value list.inspectRepositoryHealth helper for restore failure classification, restart reconciliation, and both acceptance commands. Existing Git helpers may be refactored behind it, but branch, HEAD, cleanliness, unmerged-index, and Git-operation semantics have one source of truth.validating-current-repository or checkpointing-current-repository. Keep the durable snapshot in restore-recovery-required until an accepted terminal event transitions to closed. A restart during either command therefore hydrates back into recovery-required.chat_turn_intents.terminal_outcome migration.[Interrupted] commit message and verify the checkpoint appears as an ordinary Version History entry.nextStep heuristic.[Recovery] commit message and verify that checkpoint also appears as an ordinary Version History entry.npm run fmt, npm run lint, and npm run ts before commit.The phases may be separate commits but should ship in one PR.
completed.cancelled atomically with recovery = "terminal" and queue pause state.errored.listVersions with the exact [Interrupted] label so it can be selected, previewed, and restored, andDyadErrorKind.Conflict, leaves branch/HEAD/index/tree unchanged, and does not create a fork chat.RESTORE_FAILED.canSelectVersion after reopening the pane.restore-recovery-required exposes canAcceptCurrentRepository.appId, branch, and accepted HEAD, accepts only a valid optional saved-version ID, and rejects malformed settlement facts.listVersions with the exact [Recovery] label and supports normal preview/restore behavior.[Recovery] version, and has no toast action.Prefer Vitest integration tests over Playwright because the behavior can be proven with real Git, SQLite, handlers, and the renderer/IPC harness. Use one final manual Electron sanity check for the toast interaction and packaged lifecycle.
[Interrupted]; it can be selected, previewed, and restored without Git knowledge, and no user-visible file is lost.restore-recovery-required and does not permanently disable Version History.| Risk | Impact | Mitigation |
|---|---|---|
| A cancelled marker is applied to unrelated later manual edits | Extra checkpoint content | Commit the whole tree losslessly, disclose it, and require an explicit Undo targeting that cancelled turn. |
| Internal checkpoints clutter Version History | Extra versions in the normal timeline | Keep them recoverable and visible, but distinguish them with the canonical [Interrupted] and [Recovery] prefixes instead of adding a parallel checkpoint UI. |
| Terminal outcome persistence diverges from actor completion | Incorrect provenance after restart | Persist outcome atomically with terminal recovery/queue mutation and test live plus hydrated paths. |
| Failure classification clears a genuinely partial restore | Data loss or misleading state | Preflight before checkout, classify under the repository claim, and keep hard-reset-and-later phases conservative. |
| “Use current version” becomes a disguised force-clear | Unsafe future Git mutations | Require clean attached HEAD, no unmerged entries, and no in-progress Git operation; never modify Git in this command. |
| Dirty-tree users reach another dead end | Version History remains unusable without Git knowledge | After read-only validation, offer an explicit whole-tree checkpoint action with plain-language copy and no discard/reset behavior. |
| Checkpoint commit succeeds but acknowledgement crashes | User sees recovery again despite a healthy repository | Persist recovery until settlement; on restart the now-clean repository passes Use current version safely. |
| Renderer disappears during acceptance | Lost or optimistic acknowledgement | Main owns settlement; persist recovery until success and treat renderer delivery as best-effort. |
| Recovery clears but the renderer still shows historical or stale data | User cannot tell which code/chat is authoritative | Return accepted branch/HEAD facts, clear preview presentation, publish scoped invalidations, and preserve only a surviving chat selection. |
| Dismissing the toast hides the reason Version History is locked | Disabled controls appear broken | Show an inline blocked state whenever the pane is opened during unresolved recovery and resurface the persistent notice. |
| Migration cannot identify old cancelled turns | Old Stop -> Undo remains conservative | Nullable outcome plus narrowly validated cancelled-response fallback; stale recovery still has the validated acceptance action. |
| New event expands the distributed-machine boundary incorrectly | Transport or lifecycle regression | Use existing remote intent/command runner, update exact inventories only if production call sites genuinely change, and run conformance tests. |
Add structured, non-content logs for:
active-cancel, durable-cancelled-turn, or none);Never log prompts, file contents, tokens, or remote URLs. Expected dirty-tree and repository-health refusals remain classified DyadErrors rather than exception telemetry.
chat_turn_intents; do not create another cancellation database.recovery = "terminal" alone is insufficient provenance.[Interrupted] or [Recovery] commit-message prefixes; no new version type or hidden recovery store.nextStep !== preparing heuristic.terminal_outcome once as a shared schema/string union and reuse it across persistence and domain code.inspectRepositoryHealth rather than growing divergent status checks.Plan created directly at the user’s request; no swarm session was used.