Add thread attachment operations with coordinated deletion (#44350)

## Why

Attachment mutations must coordinate with thread deletion so queued requests cannot use metadata belonging to a deleted thread.

## What changed

- Add typed `ThreadStore` operations for idempotent attachment creation, paginated listing, and removal, with capability detection and unsupported defaults.
- Implement attachment operations for SQLite-backed `LocalThreadStore`, including unloaded threads, and preserve invalid-request and missing-thread errors.
- Coordinate attachment mutations with lifecycle locks and move SQLite cleanup into thread-store deletion. Remove state rows only after rollout deletion, preserving retries after cleanup failures.
- Give the in-memory store ownership of its associated SQLite cleanup and remove duplicate cleanup from the app-server.

## Testing

Add coverage for attachment pagination and idempotency, unsupported stores, error categories, lifecycle exclusion, mutations queued behind single and batch deletion, cleanup failure retries, and in-memory state cleanup. Extend the app-server descendant deletion test to verify SQLite rows are removed.

GitOrigin-RevId: f0157538c7c5c2ee1362ce40eac157101c5d4505
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-09-10 00:30:27 +00:00
1 parent e444aa99d7
commit 2df0b747ba
11 files changed
+834 -20

No files matched your search

@@ -1,8 +1,9 @@
//! 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. The app-server deletes main state DB rows
//! after every associated rollout is removed; this module deletes local history projection rows.
//! vanishes after discovery counts as already deleted. Main state DB rows are deleted after every
//! associated rollout is removed, under the same lifecycle lock, so queued artifact mutations cannot
//! use deleted thread metadata.
use std::collections::HashMap;
use std::collections::HashSet;
@@ -72,7 +73,19 @@ pub(super) async fn delete_thread(
let thread_rollouts = ThreadRollouts::from_index(&reference_index, thread_id);
ensure_no_external_references(&reference_index, std::slice::from_ref(&thread_rollouts))?;
let mut writer_guards = store.acquire_writer_locks(&[thread_id]).await?;
delete_thread_after_reference_check(store, thread_rollouts, &mut writer_guards).await
let found_rollout =
match delete_thread_after_reference_check(store, thread_rollouts, &mut writer_guards).await
{
Ok(()) => true,
Err(ThreadStoreError::ThreadNotFound { .. }) => false,
Err(err) => return Err(err),
};
let deleted_state_rows = delete_state_rows(store, &[thread_id]).await?;
if found_rollout || deleted_state_rows > 0 {
Ok(())
} else {
Err(ThreadStoreError::ThreadNotFound { thread_id })
}
}
pub(super) async fn delete_threads(
@@ -111,9 +124,26 @@ pub(super) async fn delete_threads(
Err(err) => return Err(err),
}
}
// Retain the complete retry graph until every rollout has been removed.
delete_state_rows(store, &thread_ids).await?;
Ok(())
}
async fn delete_state_rows(
store: &LocalThreadStore,
thread_ids: &[codex_protocol::ThreadId],
) -> ThreadStoreResult<u64> {
let Some(state_db) = store.state_db.as_ref() else {
return Ok(0);
};
state_db
.delete_threads_strict(thread_ids)
.await
.map_err(|err| ThreadStoreError::Internal {
message: format!("failed to delete thread state: {err}"),
})
}
fn ensure_no_external_references(
reference_index: &RolloutReferenceIndex,
thread_rollouts: &[ThreadRollouts],