Add experimental attached thread forks (#56351)

## 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
This commit is contained in:
Brent Traut
2026-10-23 04:52:47 +00:00
committed by copyberry
parent 4610b573e5
commit 30587d7885
70 changed files with 1078 additions and 68 deletions
@@ -2697,6 +2697,7 @@ impl ThreadRequestProcessor {
search_term,
parent_thread_id,
ancestor_thread_id,
attached_to_thread_id,
} = params;
let excluded_thread_ids = excluded_thread_ids.unwrap_or_default();
if excluded_thread_ids.len() > THREAD_LIST_MAX_EXCLUDED_IDS {
@@ -2740,21 +2741,42 @@ impl ThreadRequestProcessor {
}
}
let cwd_filters = normalize_thread_list_cwd_filters(cwd)?;
let relation_filter = match (parent_thread_id, ancestor_thread_id) {
(Some(_), Some(_)) => {
let relation_filter = match (parent_thread_id, ancestor_thread_id, attached_to_thread_id) {
(Some(_), Some(_), None) => {
return Err(invalid_request(
"parentThreadId and ancestorThreadId are mutually exclusive",
));
}
(Some(parent_thread_id), None) => Some(StoreThreadRelationFilter::DirectChildrenOf(
ThreadId::from_string(&parent_thread_id)
.map_err(|err| invalid_request(format!("invalid parent thread id: {err}")))?,
)),
(None, Some(ancestor_thread_id)) => Some(StoreThreadRelationFilter::DescendantsOf(
ThreadId::from_string(&ancestor_thread_id)
.map_err(|err| invalid_request(format!("invalid ancestor thread id: {err}")))?,
)),
(None, None) => None,
(Some(parent_thread_id), None, None) => {
Some(StoreThreadRelationFilter::DirectChildrenOf(
ThreadId::from_string(&parent_thread_id).map_err(|err| {
invalid_request(format!("invalid parent thread id: {err}"))
})?,
))
}
(None, Some(ancestor_thread_id), None) => {
Some(StoreThreadRelationFilter::DescendantsOf(
ThreadId::from_string(&ancestor_thread_id).map_err(|err| {
invalid_request(format!("invalid ancestor thread id: {err}"))
})?,
))
}
(None, None, Some(parent)) => {
if self.state_db.is_none() || !self.thread_store.as_any().is::<LocalThreadStore>() {
return Err(unsupported_thread_store_operation("attached threads"));
}
Some(StoreThreadRelationFilter::AttachedThreadsOf(
ThreadId::from_string(&parent).map_err(|err| {
invalid_request(format!("invalid attached thread parent id: {err}"))
})?,
))
}
(_, _, Some(_)) => {
return Err(invalid_request(
"attachedToThreadId cannot be combined with parentThreadId or ancestorThreadId",
));
}
(None, None, None) => None,
};
let requested_page_size = limit
@@ -5269,11 +5291,22 @@ impl ThreadRequestProcessor {
base_instructions,
developer_instructions,
ephemeral,
attach_to_source,
thread_source,
exclude_turns,
defer_goal_continuation,
} = params;
let include_turns = !exclude_turns;
if attach_to_source {
if ephemeral || defer_goal_continuation {
return Err(invalid_request(
"attachToSource cannot be combined with ephemeral or deferGoalContinuation",
));
}
if self.state_db.is_none() || !self.thread_store.as_any().is::<LocalThreadStore>() {
return Err(unsupported_thread_store_operation("attached threads"));
}
}
if sandbox.is_some() && permissions.is_some() {
return Err(invalid_request(
"`permissions` cannot be combined with `sandbox`",
@@ -5645,6 +5678,7 @@ impl ThreadRequestProcessor {
};
let fork_options = StartThreadOptions {
attached_thread: attach_to_source,
thread_source,
parent_trace,
reserved_thread_id,
@@ -5877,9 +5911,11 @@ impl ThreadRequestProcessor {
.await;
}
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
if !attach_to_source {
self.outgoing
.send_server_notification(ServerNotification::ThreadStarted(notif))
.await;
}
if inherited_goal {
self.thread_goal_processor
.emit_thread_goal_snapshot(thread_id)