mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
a1a8807e9d
commit
a19d43a40a
@@ -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(¶ms.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(¶ms.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}"))
|
||||
|
||||
Reference in New Issue
Block a user