Add app-server thread/delete API (#25018)

## Why

Clients can archive and unarchive threads today, but there is no
app-server API for permanently removing a thread. Deletion also needs to
cover the full session tree: deleting a main thread should remove
spawned subagent threads and the related local metadata instead of
leaving orphaned rollout files, goals, or subagent state behind.

## What

- Adds the v2 `thread/delete` request and `thread/deleted` notification,
with the response shape kept consistent with `thread/archive`.
- Implements local hard delete for active and archived rollout files.
- Deletes the requested thread's state DB row as the commit point, then
best-effort cleans associated state including spawned descendants,
goals, spawn edges, logs, dynamic tools, and agent job assignments.
- Updates app-server API docs and generated protocol schema/TypeScript
fixtures.
This commit is contained in:
Eric Traut
2026-06-10 11:22:12 -07:00
committed by GitHub
Unverified
parent a1a8807e9d
commit a19d43a40a
38 changed files with 1464 additions and 88 deletions
+21
View File
@@ -18,6 +18,7 @@ use codex_protocol::protocol::ThreadMemoryMode;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::CreateThreadParams;
use crate::DeleteThreadParams;
use crate::ListThreadsParams;
use crate::LoadThreadHistoryParams;
use crate::ReadThreadByRolloutPathParams;
@@ -115,6 +116,7 @@ pub struct InMemoryThreadStoreCalls {
pub update_thread_metadata: usize,
pub archive_thread: usize,
pub unarchive_thread: usize,
pub delete_thread: usize,
}
/// In-memory [`ThreadStore`] implementation for tests and debug configs.
@@ -333,6 +335,25 @@ impl ThreadStore for InMemoryThreadStore {
state.calls.unarchive_thread += 1;
stored_thread_from_state(&state, params.thread_id, /*include_history*/ false)
}
async fn delete_thread(&self, params: DeleteThreadParams) -> ThreadStoreResult<()> {
let mut state = self.state.lock().await;
state.calls.delete_thread += 1;
let existed = state.histories.remove(&params.thread_id).is_some();
state.created_threads.remove(&params.thread_id);
state.names.remove(&params.thread_id);
state.metadata_updates.remove(&params.thread_id);
state
.rollout_paths
.retain(|_, thread_id| *thread_id != params.thread_id);
if existed {
Ok(())
} else {
Err(ThreadStoreError::ThreadNotFound {
thread_id: params.thread_id,
})
}
}
}
fn stored_thread_from_state(
+1
View File
@@ -25,6 +25,7 @@ pub use types::AppendThreadItemsParams;
pub use types::ArchiveThreadParams;
pub use types::ClearableField;
pub use types::CreateThreadParams;
pub use types::DeleteThreadParams;
pub use types::ExtraConfig;
pub use types::GitInfoPatch;
pub use types::ItemPage;
@@ -0,0 +1,210 @@
//! Local hard-delete support for persisted threads.
//!
//! Existing rollout files are deleted before this operation reports success. A rollout file that
//! vanishes after discovery counts as already deleted. SQLite cleanup happens at the app-server
//! layer after every associated rollout has been removed so failed deletes can be retried.
use std::io::ErrorKind;
use std::path::Path;
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
use codex_rollout::SESSIONS_SUBDIR;
use codex_rollout::find_archived_thread_path_by_id_str;
use codex_rollout::find_thread_path_by_id_str;
use codex_rollout::remove_thread_name_entries;
use super::LocalThreadStore;
use super::helpers::matching_rollout_file_name;
use super::helpers::scoped_rollout_path;
use crate::DeleteThreadParams;
use crate::ThreadStoreError;
use crate::ThreadStoreResult;
pub(super) async fn delete_thread(
store: &LocalThreadStore,
params: DeleteThreadParams,
) -> ThreadStoreResult<()> {
let thread_id = params.thread_id;
let thread_id_str = thread_id.to_string();
let state_db_ctx = store.state_db().await;
let mut rollout_paths = Vec::new();
match find_thread_path_by_id_str(
store.config.codex_home.as_path(),
thread_id_str.as_str(),
state_db_ctx.as_deref(),
)
.await
{
Ok(Some(path)) => rollout_paths.push(path),
Ok(None) => {}
Err(err) => {
return Err(ThreadStoreError::InvalidRequest {
message: format!("failed to locate thread id {thread_id}: {err}"),
});
}
}
match find_archived_thread_path_by_id_str(
store.config.codex_home.as_path(),
thread_id_str.as_str(),
state_db_ctx.as_deref(),
)
.await
{
Ok(Some(path)) => {
if !rollout_paths.contains(&path) {
rollout_paths.push(path);
}
}
Ok(None) => {}
Err(err) => {
return Err(ThreadStoreError::InvalidRequest {
message: format!("failed to locate archived thread id {thread_id}: {err}"),
});
}
}
let found_rollout_path = !rollout_paths.is_empty();
for rollout_path in rollout_paths {
delete_rollout_file(store, rollout_path.as_path(), thread_id)?;
}
remove_thread_name_entries(store.config.codex_home.as_path(), thread_id)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to delete thread name index entries for {thread_id}: {err}"),
})?;
if !found_rollout_path {
return Err(ThreadStoreError::ThreadNotFound { thread_id });
}
store.live_recorders.lock().await.remove(&thread_id);
Ok(())
}
fn delete_rollout_file(
store: &LocalThreadStore,
rollout_path: &Path,
thread_id: codex_protocol::ThreadId,
) -> ThreadStoreResult<bool> {
let plain_path = codex_rollout::plain_rollout_path(rollout_path);
let compressed_path = plain_path.with_extension("jsonl.zst");
let deleted_plain = delete_rollout_path(store, plain_path.as_path(), thread_id)?;
let deleted_compressed = delete_rollout_path(store, compressed_path.as_path(), thread_id)?;
Ok(deleted_plain || deleted_compressed)
}
fn delete_rollout_path(
store: &LocalThreadStore,
rollout_path: &Path,
thread_id: codex_protocol::ThreadId,
) -> ThreadStoreResult<bool> {
let canonical_rollout_path = scoped_rollout_path(
store.config.codex_home.join(SESSIONS_SUBDIR),
rollout_path,
"sessions",
)
.or_else(|_| {
scoped_rollout_path(
store.config.codex_home.join(ARCHIVED_SESSIONS_SUBDIR),
rollout_path,
"archived sessions",
)
})
.or_else(|err| match rollout_path.try_exists() {
Ok(false) => Ok(rollout_path.to_path_buf()),
Ok(true) | Err(_) => Err(err),
})?;
matching_rollout_file_name(&canonical_rollout_path, thread_id, rollout_path)?;
match std::fs::remove_file(&canonical_rollout_path) {
Ok(()) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(ThreadStoreError::Internal {
message: format!(
"failed to delete rollout file `{}`: {err}",
canonical_rollout_path.display()
),
}),
}
}
#[cfg(test)]
mod tests {
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use uuid::Uuid;
use super::*;
use crate::ThreadStore;
use crate::local::LocalThreadStore;
use crate::local::test_support::test_config;
use crate::local::test_support::write_archived_session_file;
use crate::local::test_support::write_session_file;
#[tokio::test]
async fn delete_thread_removes_active_and_archived_rollouts() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let active_path =
write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(301))
.expect("session file");
let compressed_path = active_path.with_extension("jsonl.zst");
std::fs::write(&compressed_path, b"compressed sibling").expect("compressed sibling");
let cases = [
(Uuid::from_u128(301), active_path),
(
Uuid::from_u128(302),
write_archived_session_file(
home.path(),
"2025-01-03T12-00-00",
Uuid::from_u128(302),
)
.expect("archived session file"),
),
];
for (uuid, path) in cases {
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
store
.delete_thread(DeleteThreadParams { thread_id })
.await
.expect("delete thread");
assert!(!path.exists());
}
assert!(!compressed_path.exists());
}
#[tokio::test]
async fn delete_rollout_file_treats_vanished_path_as_already_deleted() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let uuid = Uuid::from_u128(305);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let path =
write_session_file(home.path(), "2025-01-03T12-00-00", uuid).expect("session file");
std::fs::remove_file(&path).expect("remove session file");
assert!(!delete_rollout_file(&store, path.as_path(), thread_id).expect("delete rollout"));
}
#[tokio::test]
async fn delete_thread_reports_missing_thread() {
let home = TempDir::new().expect("temp dir");
let store = LocalThreadStore::new(test_config(home.path()), /*state_db*/ None);
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000304").expect("valid thread id");
let err = store
.delete_thread(DeleteThreadParams { thread_id })
.await
.expect_err("missing thread should fail");
assert_eq!(
err.to_string(),
"thread 00000000-0000-0000-0000-000000000304 not found"
);
}
}
+6
View File
@@ -1,5 +1,6 @@
mod archive_thread;
mod create_thread;
mod delete_thread;
mod helpers;
mod list_threads;
mod live_writer;
@@ -24,6 +25,7 @@ use tokio::sync::Mutex;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::CreateThreadParams;
use crate::DeleteThreadParams;
use crate::ListThreadsParams;
use crate::LoadThreadHistoryParams;
use crate::ReadThreadByRolloutPathParams;
@@ -288,6 +290,10 @@ impl ThreadStore for LocalThreadStore {
) -> ThreadStoreResult<StoredThread> {
unarchive_thread::unarchive_thread(self, params).await
}
async fn delete_thread(&self, params: DeleteThreadParams) -> ThreadStoreResult<()> {
delete_thread::delete_thread(self, params).await
}
}
#[cfg(test)]
+4
View File
@@ -5,6 +5,7 @@ use std::any::Any;
use crate::AppendThreadItemsParams;
use crate::ArchiveThreadParams;
use crate::CreateThreadParams;
use crate::DeleteThreadParams;
use crate::ItemPage;
use crate::ListItemsParams;
use crate::ListThreadsParams;
@@ -119,4 +120,7 @@ pub trait ThreadStore: Any + Send + Sync {
&self,
params: ArchiveThreadParams,
) -> ThreadStoreResult<StoredThread>;
/// Deletes a thread's persisted rollout data and associated metadata.
async fn delete_thread(&self, params: DeleteThreadParams) -> ThreadStoreResult<()>;
}
+7
View File
@@ -653,6 +653,13 @@ pub struct ArchiveThreadParams {
pub thread_id: ThreadId,
}
/// Parameters for deleting a thread.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeleteThreadParams {
/// Thread id to delete.
pub thread_id: ThreadId,
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;