mirror of
https://github.com/openai/codex.git
synced 2026-11-11 21:10:39 +00:00
## What changed - Add `attachToSource` to `thread/fork` to persist a fork attached to its source without emitting `thread/started`. Attached forks require local storage and cannot use `ephemeral` or `deferGoalContinuation`. - Hide attached threads from ordinary listings and add `attachedToThreadId` to `thread/list` for explicit discovery, including empty threads. Keep this relationship separate from spawned-agent parent and ancestor filters. - Persist attachment metadata in rollouts and SQLite so it survives restarts and database recovery. Reject source deletion until its attached threads are deleted. ## Testing Add coverage for legacy and paginated history, restart and missing or corrupt database recovery, filtered listings and pagination, resume, conflicting options, and deletion safeguards across store instances. GitOrigin-RevId: 589f3174fc36f6054e75d65818360695645a5913
164 lines
5.5 KiB
Rust
164 lines
5.5 KiB
Rust
//! `thread/delete` request handling.
|
|
|
|
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 thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
|
|
|
|
self.validate_root_thread_delete(thread_id, thread_ids.len() > 1)
|
|
.await?;
|
|
if let Some(store) = self
|
|
.thread_store
|
|
.as_any()
|
|
.downcast_ref::<LocalThreadStore>()
|
|
{
|
|
store
|
|
.ensure_no_attached_threads(&thread_ids)
|
|
.await
|
|
.map_err(thread_store_delete_error)?;
|
|
}
|
|
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);
|
|
|
|
self.thread_store
|
|
.delete_threads(StoreDeleteThreadsParams {
|
|
thread_ids: delete_order.clone(),
|
|
})
|
|
.await
|
|
.map_err(thread_store_delete_error)?;
|
|
|
|
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,
|
|
) -> Result<(), JSONRPCErrorError> {
|
|
self.prepare_thread_for_removal(thread_id, "delete").await?;
|
|
if let Some(log_db) = self.log_db.as_ref() {
|
|
log_db.flush().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
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 } | ThreadStoreError::Conflict { message } => {
|
|
invalid_request(message)
|
|
}
|
|
ThreadStoreError::Unsupported { operation } => {
|
|
unsupported_thread_store_operation(operation)
|
|
}
|
|
err => internal_error(format!("failed to delete thread: {err}")),
|
|
}
|
|
}
|