fix(resume): keep the interrupted turn when --resume follows a killed TUI

CW-7453. After the TUI is killed mid-turn, `codewhale --resume <id>`
opened the session with no error but without the interrupted turn: the
next request carried only the system prompt and the new message.

Cause. prepare_mounted_session captured the stale saved document and
committed a canonical Resume whose updated_at is the operation time. The
TUI's attach then ran promote_interrupted_checkpoint, which saw the saved
document as newer than the crash checkpoint, skipped promotion and deleted
the checkpoint, the only copy of the turn. `--continue` was unaffected
because its recovery path promotes the checkpoint before the mount.

Fix. prepare_mounted_session promotes the session's crash checkpoint under
the external-write lease it already holds, before reading the document, so
the mount captures the recovered turn. promote_interrupted_checkpoint
becomes pub(crate). Fail-closed behavior is unchanged: that lease refuses a
session live in this or another process. A fork of a crashed session now
promotes the source's checkpoint first, which any later attach of the
source would have done.

Known limit, recorded on CW-7453: promote_interrupted_checkpoint still
judges staleness by updated_at alone, so a metadata-only write that bumps
a crashed session's document would still make the next attach drop its
checkpoint.

Verified with a real crash repro (CW/artifacts/cw7446-20261018,
crashcont.sh): isolated CODEWHALE_HOME, a local mock provider with a 15 s
stream, tmux kill-server mid-stream, 8 s wait with no codewhale process
left, relaunch, then a follow-up message. The same script ran against a
pre-fix and a fixed build of this tree:
  pre-fix --resume:   follow-up request messages=2, turn gone
  fixed   --resume:   follow-up request messages=3, turn on screen
  pre-fix --continue: messages=3 (unchanged)
  fixed   --continue: messages=3 (unchanged)
scripts/dev-test.sh tui mounted interrupted_checkpoint attach_session:
24 run, 24 passed (14 mounted_history tests). scripts/dev-test.sh tui
session_manager:: : 137 run, 137 passed. cargo fmt --check clean;
sync-changelog --check up to date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-17 22:08:57 -07:00
1 parent 88edb22461
commit 3111e961c0
4 files changed
+14 -3

No files matched your search

+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `codewhale --resume <id>` after the TUI was killed mid-turn keeps the
interrupted turn. Before, the resume stamped the older saved document as
newer than the crash checkpoint, and the checkpoint, the only copy of
that turn, was then discarded as stale. `--continue` was not affected.
- Three session data-loss paths are closed:
- `/load` of an older exported copy of a session no longer replaces the
newer saved conversation with the same id. It refuses and points to
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- `codewhale --resume <id>` after the TUI was killed mid-turn keeps the
interrupted turn. Before, the resume stamped the older saved document as
newer than the crash checkpoint, and the checkpoint, the only copy of
that turn, was then discarded as stale. `--continue` was not affected.
- Three session data-loss paths are closed:
- `/load` of an older exported copy of a session no longer replaces the
newer saved conversation with the same id. It refuses and points to
+5 -2
View File
@@ -9967,9 +9967,12 @@ async fn prepare_mounted_session(
} else {
manager.resolve_session_id_prefix(&selector)?
};
// Capture the complete document and separate local goal under the
// same existing inactive-session guard; neither read repairs history.
// Under the existing inactive-session guard, promote any crash
// checkpoint first: the mount below stamps a newer document, and the
// later attach would then discard the checkpoint as stale. Then
// capture the complete document and separate local goal.
let _source_lease = manager.reserve_session_for_external_write(&id)?;
manager.promote_interrupted_checkpoint(&id);
let saved = manager.load_session_snapshot_bounded(&id, MAX_CANONICAL_HISTORY_BYTES)?;
// Project grants were captured for this launch before awaiting. The
// existing restore changes App workspace, so it cannot silently apply
+1 -1
View File
@@ -3577,7 +3577,7 @@ impl SessionManager {
/// consume it. Callers hold the session's attach lease. A stale checkpoint
/// never replaces a newer document; an unreadable document and a failed
/// save leave both files as they were.
fn promote_interrupted_checkpoint(&self, id: &str) {
pub(crate) fn promote_interrupted_checkpoint(&self, id: &str) {
let Ok(Some(checkpoint)) = self.load_session_checkpoint(id) else {
return;
};