mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
1 parent
8f6727ddd0
commit
3815a5a583
4 files changed
+62
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
Reference in new issue
Block a user