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
+7 -1
View File
@@ -400,7 +400,7 @@ impl MessageProcessor {
Arc::clone(&thread_manager),
Arc::clone(&config),
feedback,
log_db,
log_db.clone(),
state_db.clone(),
);
let git_processor = GitRequestProcessor::new();
@@ -454,6 +454,7 @@ impl MessageProcessor {
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db,
log_db,
Arc::clone(&skills_watcher),
);
let turn_processor = TurnRequestProcessor::new(
@@ -1071,6 +1072,11 @@ impl MessageProcessor {
.thread_archive(request_id.clone(), params)
.await
}
ClientRequest::ThreadDelete { params, .. } => {
self.thread_processor
.thread_delete(request_id.clone(), params)
.await
}
ClientRequest::ThreadIncrementElicitation { params, .. } => {
self.thread_processor
.thread_increment_elicitation(params)
@@ -183,6 +183,9 @@ use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoal;
@@ -419,6 +422,7 @@ use codex_rollout::state_db::reconcile_rollout;
use codex_state::ThreadMetadata;
use codex_state::log_db::LogDbLayer;
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
use codex_thread_store::DeleteThreadParams as StoreDeleteThreadParams;
use codex_thread_store::GitInfoPatch as StoreGitInfoPatch;
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
@@ -539,6 +543,7 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec
mod config_errors;
mod request_errors;
mod thread_delete;
mod thread_goal_processor;
mod thread_lifecycle;
mod thread_resume_redaction;
@@ -0,0 +1,188 @@
//! `thread/delete` request handling.
use super::thread_processor::core_thread_write_error;
use super::thread_processor::unsupported_thread_store_operation;
use super::*;
impl ThreadRequestProcessor {
pub(crate) async fn thread_delete(
&self,
request_id: ConnectionRequestId,
params: ThreadDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let mut deleted_thread_ids = Vec::new();
let result = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_delete_response(params, &mut deleted_thread_ids)
.await
};
match result {
Ok(response) => {
self.outgoing
.send_response(request_id.clone(), response)
.await;
self.send_thread_deleted_notifications(deleted_thread_ids)
.await;
Ok(None)
}
Err(error) => Err(error),
}
}
async fn thread_delete_response(
&self,
params: ThreadDeleteParams,
deleted_thread_ids: &mut Vec<String>,
) -> Result<ThreadDeleteResponse, JSONRPCErrorError> {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let mut thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
let mut seen = thread_ids.iter().copied().collect::<HashSet<_>>();
match self
.thread_manager
.list_agent_subtree_thread_ids(thread_id)
.await
{
Ok(live_thread_ids) => {
for live_thread_id in live_thread_ids {
if seen.insert(live_thread_id) {
thread_ids.push(live_thread_id);
}
}
}
Err(err) => return Err(core_thread_write_error("delete thread", err)),
}
self.validate_root_thread_delete(thread_id, thread_ids.len() > 1)
.await?;
for thread_id_to_delete in thread_ids.iter().copied() {
self.prepare_thread_for_delete(thread_id_to_delete).await;
}
let mut delete_order: Vec<_> = thread_ids.iter().skip(1).rev().copied().collect();
delete_order.push(thread_id);
for thread_id_to_delete in delete_order.iter().copied() {
match self
.thread_store
.delete_thread(StoreDeleteThreadParams {
thread_id: thread_id_to_delete,
})
.await
{
Ok(()) => {}
Err(ThreadStoreError::ThreadNotFound { .. }) => {
warn!(
"thread {thread_id_to_delete} was already missing while deleting {thread_id}"
);
}
Err(err) => {
return Err(thread_store_delete_error(err));
}
}
}
if let Some(state_db) = self.state_db.as_ref() {
state_db
.delete_threads_strict(thread_ids.as_slice())
.await
.map_err(|err| {
internal_error(format!(
"failed to delete app-server state for {thread_id}: {err}"
))
})?;
}
deleted_thread_ids.extend(
delete_order
.into_iter()
.map(|thread_id| thread_id.to_string()),
);
Ok(ThreadDeleteResponse {})
}
async fn send_thread_deleted_notifications(&self, deleted_thread_ids: Vec<String>) {
for thread_id in deleted_thread_ids {
self.outgoing
.send_server_notification(ServerNotification::ThreadDeleted(
ThreadDeletedNotification { thread_id },
))
.await;
}
}
async fn validate_root_thread_delete(
&self,
thread_id: ThreadId,
has_descendants: bool,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if !thread.config_snapshot().await.ephemeral {
return Ok(());
}
return Err(invalid_request(format!(
"thread is not persisted and cannot be deleted: {thread_id}"
)));
}
match self
.thread_store
.read_thread(StoreReadThreadParams {
thread_id,
include_archived: true,
include_history: false,
})
.await
{
Ok(_) => Ok(()),
Err(ThreadStoreError::ThreadNotFound { .. }) => {
if has_descendants {
return Ok(());
}
let Some(state_db) = self.state_db.as_ref() else {
return Err(thread_store_delete_error(
ThreadStoreError::ThreadNotFound { thread_id },
));
};
if state_db
.get_thread(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to read app-server state for {thread_id}: {err}"
))
})?
.is_some()
{
Ok(())
} else {
Err(thread_store_delete_error(
ThreadStoreError::ThreadNotFound { thread_id },
))
}
}
Err(err) => Err(thread_store_delete_error(err)),
}
}
async fn prepare_thread_for_delete(&self, thread_id: ThreadId) {
self.prepare_thread_for_removal(thread_id, "delete").await;
if let Some(log_db) = self.log_db.as_ref() {
log_db.flush().await;
}
}
}
fn thread_store_delete_error(err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
err => internal_error(format!("failed to delete thread: {err}")),
}
}
@@ -325,6 +325,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) thread_list_state_permit: Arc<Semaphore>,
pub(super) thread_goal_processor: ThreadGoalRequestProcessor,
pub(super) state_db: Option<StateDbHandle>,
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
}
@@ -356,6 +357,7 @@ impl ThreadRequestProcessor {
thread_list_state_permit: Arc<Semaphore>,
thread_goal_processor: ThreadGoalRequestProcessor,
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
) -> Self {
Self {
@@ -372,6 +374,7 @@ impl ThreadRequestProcessor {
thread_list_state_permit,
thread_goal_processor,
state_db,
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
}
@@ -696,7 +699,7 @@ impl ThreadRequestProcessor {
Ok((thread_id, thread))
}
async fn acquire_thread_list_state_permit(
pub(super) async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
@@ -768,6 +771,10 @@ impl ThreadRequestProcessor {
}
async fn prepare_thread_for_archive(&self, thread_id: ThreadId) {
self.prepare_thread_for_removal(thread_id, "archive").await;
}
pub(super) async fn prepare_thread_for_removal(&self, thread_id: ThreadId, operation: &str) {
let removed_conversation = self.thread_manager.remove_thread(&thread_id).await;
if let Some(conversation) = removed_conversation {
info!("thread {thread_id} was active; shutting down");
@@ -775,11 +782,11 @@ impl ThreadRequestProcessor {
ThreadShutdownResult::Complete => {}
ThreadShutdownResult::SubmitFailed => {
error!(
"failed to submit Shutdown to thread {thread_id}; proceeding with archive"
"failed to submit Shutdown to thread {thread_id}; proceeding with {operation}"
);
}
ThreadShutdownResult::TimedOut => {
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
warn!("thread {thread_id} shutdown timed out; proceeding with {operation}");
}
}
}
@@ -1312,23 +1319,7 @@ impl ThreadRequestProcessor {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid session id: {err}")))?;
let mut thread_ids = vec![thread_id];
if let Some(state_db_ctx) = self.state_db.as_ref() {
let descendants = state_db_ctx
.list_thread_spawn_descendants(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to list spawned descendants for session {thread_id}: {err}"
))
})?;
let mut seen = HashSet::from([thread_id]);
for descendant_id in descendants {
if seen.insert(descendant_id) {
thread_ids.push(descendant_id);
}
}
}
let thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
let mut archive_thread_ids = Vec::new();
match self
@@ -1413,6 +1404,31 @@ impl ThreadRequestProcessor {
Ok((ThreadArchiveResponse {}, archived_thread_ids))
}
pub(super) async fn state_db_spawn_subtree_thread_ids(
&self,
thread_id: ThreadId,
) -> Result<Vec<ThreadId>, JSONRPCErrorError> {
let mut thread_ids = vec![thread_id];
let Some(state_db_ctx) = self.state_db.as_ref() else {
return Ok(thread_ids);
};
let mut seen = HashSet::from([thread_id]);
let descendants = state_db_ctx
.list_thread_spawn_descendants(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to list spawned descendants for thread id {thread_id}: {err}"
))
})?;
for descendant_id in descendants {
if seen.insert(descendant_id) {
thread_ids.push(descendant_id);
}
}
Ok(thread_ids)
}
async fn thread_increment_elicitation_inner(
&self,
params: ThreadIncrementElicitationParams,
@@ -3909,7 +3925,7 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
}
}
fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
pub(super) fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
method_not_found(format!("{operation} is not supported yet"))
}
@@ -4030,7 +4046,7 @@ fn conversation_summary_rollout_path_read_error(
}
}
fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
pub(super) fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
match err {
CodexErr::ThreadNotFound(thread_id) => {
invalid_request(format!("thread not found: {thread_id}"))