ultraworkers/claw-code · error · SessionError

session file was removed during save (possible concurrent mo

Error message

session file was removed during save (possible concurrent modification): {io_err}

What it means

Returned by Session::save_to_path (session.rs:235-246, issue #112) when the rotate step fails with ENOENT. Before writing, save_to_path renames an oversized session file to a rotated *.rot-*.jsonl sibling; if that rename reports NotFound, the error is re-wrapped with this message to flag concurrent modification. In practice the ENOENT almost always means the session file (or its parent directory) vanished between the metadata check in rotate_session_file_if_needed and the rename at session.rs:1370 — i.e. another process or thread deleted it mid-save.

Source

Thrown at rust/crates/runtime/src/session.rs:238

    #[must_use]
    pub fn workspace_root(&self) -> Option<&Path> {
        self.workspace_root.as_deref()
    }

    #[must_use]
    pub fn persistence_path(&self) -> Option<&Path> {
        self.persistence.as_ref().map(|value| value.path.as_path())
    }

    pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
        let path = path.as_ref();
        let snapshot = self.render_jsonl_snapshot()?;
        // #112: wrap ENOENT during rotate as concurrent modification
        match rotate_session_file_if_needed(path) {
            Ok(()) => {}
            Err(SessionError::Io(ref io_err)) if io_err.kind() == std::io::ErrorKind::NotFound => {
                return Err(SessionError::Io(std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    format!(
                        "session file was removed during save (possible concurrent modification): {io_err}"
                    ),
                )));
            }
            Err(e) => return Err(e),
        }
        write_atomic(path, &snapshot).map_err(|e| {
            // #112: wrap ENOENT during write as concurrent modification
            match &e {
                SessionError::Io(io_err) if io_err.kind() == std::io::ErrorKind::NotFound => {
                    SessionError::Io(std::io::Error::new(
                        std::io::ErrorKind::NotFound,
                        format!("session file was removed during write (possible concurrent modification): {io_err}"),
                    ))
                }
                _ => e,

View on GitHub (pinned to 08106b0c37)

Solutions

  1. Ensure only one process writes a given session file path; serialize saves with your own file lock if multiple writers are unavoidable
  2. Move session storage out of /tmp or exclude *.jsonl from tmpwatch/systemd-tmpfiles cleanup rules
  3. In tests, join/abort background save tasks before letting the tempdir drop
  4. On receiving this error, treat it as a lost-write signal: reload via Session::load_from_path (or start a new session) instead of retrying blindly, since the on-disk state is gone

Example fix

// before — retry the same save against a deleted file, loops forever
loop {
    if session.save_to_path(&path).is_ok() { break; }
}

// after — on NotFound, accept the loss and rebind to a fresh path
match session.save_to_path(&path) {
    Ok(()) => {}
    Err(SessionError::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
        session = Session::load_from_path(&new_path) // or Session::new()
            .with_persistence_path(new_path.clone());
        session.save_to_path(&new_path)?;
    }
    Err(e) => return Err(e),
}
Defensive patterns

Strategy: retry

Validate before calling

fn session_path_writable(path: &std::path::Path) -> bool {
    std::fs::metadata(path).map(|m| m.is_file()).unwrap_or(false)
        && std::fs::metadata(path.parent().unwrap_or(path)).map(|m| m.is_dir()).unwrap_or(false)
}

Try / catch

fn is_concurrent_modification(err: &SessionError) -> bool {
    matches!(err, SessionError::Io(e)
        if e.kind() == std::io::ErrorKind::NotFound
            && e.to_string().contains("concurrent modification"))
}

// usage: on this error, reload from the last known-good state or rebind
// to a new path and save once — the old file is gone, blind retries cannot win.

Prevention

When it happens

Trigger: Session::save_to_path called on a session whose file grew past ROTATE_AFTER_BYTES, while another claw process, a cleanup job (tmpwatch/systemd-tmpfiles), or a test's tempdir drop removes the file or its directory between rotate's fs::metadata and fs::rename. Also two instances saving the same session path concurrently, one rotating while the other unlinks.

Common situations: Two terminal windows running claw against the same session file; session files stored in /tmp subject to cleanup daemons; test suites that drop tempfile dirs while a background save thread is still running; external tooling (fim/tombstone scripts) pruning .jsonl files by age.

Related errors


AI-assisted analysis of ultraworkers/claw-code@08106b0c37 (2026-08-18). Data as JSON: /api/errors/ca10a6cf92c542c6. Report an issue: GitHub.