fix(session): three data-loss paths in load, journal archive and checkpoints

Found by the 2026-10-17 safety hunt, round 3. Each was confirmed by an
adversarial verifier. Receipts:
CW/artifacts/failopen-hunt-20261017/round3.json and round3-verify.json.

- attach_session_file (/load of a file outside the store) reserved the
  id's lease and adopted the file with no comparison to an existing
  managed sessions/<id>.json. So `/save ~/x.json`, more turns, then
  `/load ~/x.json` made the next autosave replace the newer document,
  deleting every turn made after the save. It now refuses when the
  managed record's updated_at is newer (strict `>`, so an identical copy
  still loads), with an AlreadyExists error that names /resume. This is
  the rule promote_interrupted_checkpoint and saved_session_is_newer
  already apply. apply.rs already surfaces the error and keeps the
  current session.
- append_journal_archive seeked to the end and appended. An append cut
  short by ENOSPC or a kill leaves a tail with no newline. The next
  bounded save re-plans the same ids and glued them onto it.
  load_journal_archive tolerates a bad line only as the unterminated last
  line, so the whole archive became unreadable: /branch to an archived
  entry, session export and /tree failed. The document had already
  dropped those entries. The append now truncates a torn tail back to the
  last newline; only a failed append leaves one, and its entries were
  never pruned. A failed write also rolls the file back to where it
  started.
- preserve_interrupted_checkpoint_for_explicit_resume ignored
  save_checkpoint's error and then deleted the legacy checkpoint anyway.
  On a full disk, or with a changed runtime binding, the in-flight turn's
  only recovery record was gone. It now clears the legacy file only after
  the migration succeeds, as --continue already does.

Verification: the session_manager and legacy_checkpoint filters in the
4085-test run described in 864d0db02a cover these; none of its failures
are in these paths. clippy with the CI flags: clean. fmt: clean.
Not run: an ENOSPC fault injection.

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 17:24:04 -07:00
1 parent 8f6727ddd0
commit 3815a5a583
4 files changed
+62 -6

No files matched your search

+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- 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
`/resume`. Before, the next autosave deleted every turn made after the
export.
- A journal archive append that was cut short (disk full, or a kill)
is trimmed back to its last complete line before the next append.
Before, the next save glued new entries onto the torn line. That made
the whole archive unreadable, and the document had already dropped
those entries.
- Launching with a legacy crash checkpoint keeps it when migrating it to
the per-session format fails, instead of deleting it.
- Sub-agents stay within a turn's tool allow-list. `exec --allowed-tools`, a
Runtime API `allowed_tools`, and a command's `allowed-tools:` frontmatter
narrowed only the parent. A sub-agent it started got the full tool
+12
View File
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- 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
`/resume`. Before, the next autosave deleted every turn made after the
export.
- A journal archive append that was cut short (disk full, or a kill)
is trimmed back to its last complete line before the next append.
Before, the next save glued new entries onto the torn line. That made
the whole archive unreadable, and the document had already dropped
those entries.
- Launching with a legacy crash checkpoint keeps it when migrating it to
the per-session format fails, instead of deleting it.
- Sub-agents stay within a turn's tool allow-list. `exec --allowed-tools`, a
Runtime API `allowed_tools`, and a command's `allowed-tools:` frontmatter
narrowed only the parent. A sub-agent it started got the full tool
+3 -2
View File
@@ -12918,8 +12918,9 @@ fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path)
if newest.source == session_manager::CheckpointSource::Legacy {
// Migrate legacy single-slot checkpoint to per-session format
// before clearing the legacy file, or the data is unrecoverable.
let _ = manager.save_checkpoint(&newest.session);
let _ = manager.clear_legacy_checkpoint();
if manager.save_checkpoint(&newest.session).is_ok() {
let _ = manager.clear_legacy_checkpoint();
}
}
let age_str = checkpoint_age_label(newest.age);
+35 -4
View File
@@ -2877,7 +2877,7 @@ impl SessionManager {
session_id: &str,
entries: &[&SessionEntry],
) -> io::Result<()> {
use std::io::{Seek as _, SeekFrom, Write as _};
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
if entries.is_empty() {
return Ok(());
}
@@ -2901,9 +2901,27 @@ impl SessionManager {
}
let created_file = !path.exists();
let mut file = open_private_lock_file(&path)?;
file.seek(SeekFrom::End(0))?;
file.write_all(&buf)?;
file.sync_all()?;
let mut start = file.seek(SeekFrom::End(0))?;
if start > 0 {
let mut last = [0u8; 1];
file.seek(SeekFrom::End(-1))?;
file.read_exact(&mut last)?;
if last[0] != b'\n' {
let mut raw = Vec::new();
file.seek(SeekFrom::Start(0))?;
file.read_to_end(&mut raw)?;
start = raw
.iter()
.rposition(|byte| *byte == b'\n')
.map_or(0, |index| index as u64 + 1);
file.set_len(start)?;
}
file.seek(SeekFrom::Start(start))?;
}
if let Err(error) = file.write_all(&buf).and_then(|()| file.sync_all()) {
let _ = file.set_len(start).and_then(|()| file.sync_all());
return Err(error);
}
#[cfg(unix)]
if created_file || created_dir {
// Make the new directory entries durable, not only the bytes.
@@ -3608,6 +3626,19 @@ impl SessionManager {
.map(|(recovery, lease)| (recovery.session, lease));
}
let lease = self.reserve_session_for_attach(&parsed.metadata.id)?;
if let Ok(saved) = self.load_session_metadata_by_id(&parsed.metadata.id)
&& saved.updated_at > parsed.metadata.updated_at
{
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"session {} has a newer saved record than {}; continue it with `/resume {}`, or move that record aside before loading this older copy",
parsed.metadata.id,
path.display(),
parsed.metadata.id
),
));
}
let mut session = parsed;
if let Some(journal) = session.journal.as_ref() {
validate_saved_journal(journal)?;