feat(runtime): DELETE /v1/threads/{id}, and POST /v1/threads takes parent_thread_id

DELETE /v1/threads/{id} (new runtime_api/thread_delete.rs) removes an
idle thread: its turns, items, events file, goal, seed journal and
approval grants, and stops its cached engine, all under the active-
thread lock and the store's thread mutation lock. It refuses with 409
while a turn runs, and while an active automation or a pending or
dispatching trigger delivers into the thread (naming them, so the
client cancels or pauses them first rather than leaving a schedule that
would fail at fire time). The saved session bound to the thread is
deleted too unless another thread is bound to it; if the session store
refuses (an open terminal holds it), the thread is still deleted and
the answer says session_deleted: false with the reason. Tasks and child
threads that name the deleted id keep it (documented).

POST /v1/threads accepts parent_thread_id naming an existing thread
(400-class error otherwise), complementing 0da2b3d704's fork links so a
client can nest a conversation it creates. Registry row thread-delete;
the two Unreleased changelog lines picked up by ccda707286 now describe
the landed `patch` form of hunk writes, not the superseded `hunks` one.

Built on origin/main from the shared checkout's verified edits (the
checkout was 3 ahead/82 behind with other writers' work, so this commit
was assembled in a temporary index and contains only these 9 paths).

Evidence (shared working tree = these edits + others' uncommitted work):
RUST_MIN_STACK=8388608 cargo test -p codewhale-tui --lib --
  fork summary create_thread delete runtime_api::git tools::tasks
test result: FAILED. 271 passed; 2 failed; 1 ignored. Both failures are
tools::subagent::{limits_tests::root_fork_of_depth_two_leaf_cannot_regain_a_generation,
completion_usage_tests::completion_usage_counts_real_manifest_only_root_fork},
matched by the "fork" filter, in the DSH session's uncommitted
tools/subagent edits; this change touches nothing there.
cargo check --workspace --all-targets fails only on dead-code in that
same uncommitted tools/subagent/mod.rs and tools/codemode.rs.
feature_registry was queued behind the build lock and had not run when
this was committed. No live Engine run of the delete route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-27 16:47:15 -07:00
1 parent 7a27498a8d
commit 6793371e4e
9 files changed
+170 -8

No files matched your search

+2 -2
View File
@@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take a `patch` holding hunks of the one path in `paths` and apply only those hunks through `git apply`, under the same `expect` preconditions as whole-path writes. A patch that no longer applies fails with git's message and writes nothing; renames, mode changes and binary patches are refused. The tasks tool can also `suggest` a task, shown as a Start/Hide card, without starting anything.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
+2 -2
View File
@@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Interrupted session hint stays visible (#7095):** when a plain `codewhale` launch finds an in-flight session snapshot, the "Run `codewhale --continue` to resume it" hint now appears in the TUI's status line instead of being printed to the terminal just before the TUI covered it.
- **Crate split S24a (internal):** the workshop byte-budget slot moves to `codewhale-runtime`, `AutoRouteDecisionEvidence` to `codewhale-models`, and cost-status session persistence to a tui `usage_persistence` module, so billing no longer reaches the session manager or model routing. No behavior change. (CW-6129)
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take `hunks` (the `@@ … @@` headers from `GET /v1/diff`) with one path and apply only those hunks through `git apply`, honouring the same `expect` preconditions as whole-path writes. A hunk that no longer exists answers `409 git_state_changed` with `stale_hunks`, and nothing is written unless git accepts the whole patch.
- **Nested and deletable threads (Runtime API):** a fork (including a backtrack fork) records `parent_thread_id`, `POST /v1/threads` accepts one naming an existing thread, and `GET /v1/threads/summary` returns it so clients can nest child conversations. `DELETE /v1/threads/{id}` removes an idle thread with its turns, items, events and goal, and its saved session unless another thread shares it or a terminal holds it open; a running thread answers 409.
- **Hunk actions in Review (Runtime API):** `POST /v1/git/stage`, `/v1/git/unstage` and `/v1/git/discard` take a `patch` holding hunks of the one path in `paths` and apply only those hunks through `git apply`, under the same `expect` preconditions as whole-path writes. A patch that no longer applies fails with git's message and writes nothing; renames, mode changes and binary patches are refused. The tasks tool can also `suggest` a task, shown as a Start/Hide card, without starting anything.
- **Provider pre-cuts B (internal):** client wire helpers, reasoning-effort mapping and the Ollama catalog parsers move into their own client modules, and test-only provider gates use the shared test-isolation predicate. No behavior change. (CW-6129)
- **`codewhale-dependencies`, `codewhale-snapshot`, `codewhale-lsp`, `codewhale-repl` crates:** external-tool resolution, workspace snapshots, the language-server client and the Python REPL runtime moved out of `codewhale-tui` into their own crates; `write_context_file` now lives in `codewhale-repl`. No behavior change. (CW-6129)
- **`codewhale-skill-install` crate:** the community-skill installer, package content digest and `SKILL.md` frontmatter reader move out of `codewhale-tui` into a new crate; the install SSRF guard and timeouts are unchanged in production builds. (CW-6129)
+7 -1
View File
@@ -120,6 +120,7 @@ mod secrets;
pub(crate) mod sessions;
mod targets;
mod terminal;
mod thread_delete;
pub(crate) mod thread_history;
mod turn_artifacts;
mod voice;
@@ -2695,7 +2696,12 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/threads/{id}/notices/{notice_id}",
delete(ack_thread_notice),
)
.route("/v1/threads/{id}", get(get_thread).patch(update_thread))
.route(
"/v1/threads/{id}",
get(get_thread)
.patch(update_thread)
.delete(thread_delete::delete_thread),
)
.route(
"/v1/threads/{id}/history",
get(thread_history::snapshot_thread_history),
+1 -1
View File
@@ -1093,7 +1093,7 @@ pub(super) fn session_to_detail(session: SavedSession) -> SessionDetailResponse
}
}
fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError {
pub(super) fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError {
match err.kind() {
std::io::ErrorKind::NotFound => ApiError::not_found(format!("Session '{id}' not found")),
std::io::ErrorKind::InvalidData => {
@@ -0,0 +1,89 @@
use axum::Json;
use axum::extract::{Path, State};
use serde_json::{Value, json};
use crate::automation_manager::{AutomationStatus, DelayedTriggerStatus};
use crate::runtime_threads::ThreadListFilter;
use crate::session_manager::SessionManager;
use super::sessions::map_session_err;
use super::{ApiError, RuntimeApiState, map_thread_err};
pub(super) async fn delete_thread(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
) -> Result<Json<Value>, ApiError> {
{
let automations = state.automations.lock().await;
let mut scheduled: Vec<String> = automations
.list_automations()
.map_err(|e| ApiError::internal(format!("Failed to list automations: {e}")))?
.into_iter()
.filter(|a| {
a.status == AutomationStatus::Active
&& a.destination_thread_id.as_deref() == Some(id.as_str())
})
.map(|a| format!("automation {}", a.id))
.collect();
for status in [DelayedTriggerStatus::Pending, DelayedTriggerStatus::Dispatching] {
scheduled.extend(
automations
.list_triggers(Some(status), None)
.map_err(|e| ApiError::internal(format!("Failed to list triggers: {e}")))?
.into_iter()
.filter(|t| t.destination_thread_id.as_deref() == Some(id.as_str()))
.map(|t| format!("trigger {}", t.trigger_id)),
);
}
if !scheduled.is_empty() {
return Err(ApiError::conflict(format!(
"thread {id} still receives scheduled messages ({}); cancel or pause them first",
scheduled.join(", ")
)));
}
}
let thread = state
.runtime_threads
.delete_thread(&id)
.await
.map_err(map_thread_err)?;
tracing::info!(thread_id = %id, "runtime thread deleted");
let Some(session_id) = thread.session_id.clone() else {
return Ok(Json(json!({ "deleted": id, "session_deleted": false })));
};
let shared = state
.runtime_threads
.list_threads(ThreadListFilter::IncludeArchived, None)
.await
.map_err(map_thread_err)?
.iter()
.any(|other| other.session_id.as_deref() == Some(session_id.as_str()));
if shared {
return Ok(Json(json!({
"deleted": id,
"session_id": session_id,
"session_deleted": false,
"session_kept": "another thread is bound to this saved session",
})));
}
let sessions_dir = state.sessions_dir.clone();
let target = session_id.clone();
let outcome = tokio::task::spawn_blocking(move || {
let manager = SessionManager::new(sessions_dir)
.map_err(|e| ApiError::internal(format!("Failed to open sessions dir: {e}")))?;
manager
.delete_session(&target)
.map_err(|e| map_session_err(&target, e, "delete"))
})
.await
.map_err(|_| ApiError::internal("session delete failed"))?;
Ok(Json(match outcome {
Ok(()) => json!({ "deleted": id, "session_id": session_id, "session_deleted": true }),
Err(error) => json!({
"deleted": id,
"session_id": session_id,
"session_deleted": false,
"session_error": error.message,
}),
}))
}
+2
View File
@@ -542,6 +542,7 @@ impl RuntimeChatRelayHost {
let native_thread_id = self
.manager
.create_thread(CreateThreadRequest {
parent_thread_id: None,
operation_key: None,
model: Some(command.model.clone()),
model_provider: Some(command.model_provider.clone()),
@@ -698,6 +699,7 @@ impl RuntimeChatRelayHost {
command.system_prompt.as_deref(),
)),
task_id: None,
parent_thread_id: None,
dynamic_tools: Vec::new(),
environments: Vec::new(),
})
+46 -1
View File
@@ -5576,6 +5576,8 @@ pub struct CreateThreadRequest {
pub dynamic_tools: Vec<DynamicToolSpec>,
#[serde(default)]
pub environments: Vec<TurnEnvironmentParams>,
#[serde(default, alias = "parentThreadId")]
pub parent_thread_id: Option<String>,
}
/// Mutable fields accepted by `PATCH /v1/threads/{id}`.
@@ -11653,6 +11655,16 @@ impl RuntimeThreadManager {
let trust_mode = req.trust_mode.unwrap_or(false);
let auto_approve = policy.auto_approve();
let parent_thread_id = match req.parent_thread_id.as_deref().map(str::trim) {
Some(parent) => {
let parent = validated_record_id(parent, "parent thread id")?.to_string();
if self.store.load_thread(&parent).is_err() {
bail!("parent_thread_id {parent} does not name a thread");
}
Some(parent)
}
None => None,
};
let thread = ThreadRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: match reserved_id {
@@ -11677,7 +11689,7 @@ impl RuntimeThreadManager {
archived: req.archived,
system_prompt: req.system_prompt,
task_id: req.task_id,
parent_thread_id: None,
parent_thread_id,
title: None,
session_id: None,
saved_session_checkpoint: None,
@@ -11828,6 +11840,39 @@ impl RuntimeThreadManager {
Ok(())
}
pub async fn delete_thread(&self, thread_id: &str) -> Result<ThreadRecord> {
let mut active = self.active.lock().await;
if active
.engines
.get(thread_id)
.is_some_and(|state| state.active_turn.is_some())
{
bail!("thread {thread_id} already has an active turn; stop it before deleting");
}
let _thread_mutation = self.store.thread_mutation.lock();
let thread = self.store.load_thread(thread_id)?;
active.lru.retain(|id| id != thread_id);
active.shell_managers.remove(thread_id);
let evicted = active.engines.remove(thread_id).map(|state| state.engine);
for turn in self.store.list_turns_for_thread(thread_id)? {
for item_id in &turn.item_ids {
self.store.remove_item(item_id)?;
}
self.store.remove_turn(&turn.id)?;
}
remove_file_if_exists(&self.store.events_path(thread_id)?)?;
remove_file_if_exists(&self.store.goal_path(thread_id)?)?;
self.store.remove_seed_journal(thread_id)?;
self.store.remove_thread(thread_id)?;
drop(active);
if let Some(engine) = evicted {
engine.cancel_with_reason(crate::core::engine::CancelReason::Internal);
let _ = engine.try_send(Op::Shutdown);
}
self.take_approval_grants(thread_id);
Ok(thread)
}
pub async fn list_threads(
&self,
filter: ThreadListFilter,
+11 -1
View File
@@ -1173,7 +1173,17 @@ and live state comes only from a resumed thread's SSE stream.
- `GET /v1/threads/{id}`
- `PATCH /v1/threads/{id}` (see body shape below)
- `POST /v1/threads/{id}/resume`
- `POST /v1/threads/{id}/fork`
- `DELETE /v1/threads/{id}` — removes the thread with its turns, items,
events, goal and approval grants, and stops its cached engine. A thread
with a running turn, or one that an active automation or a pending
trigger delivers into, answers `409` naming them. The saved session bound to it is
deleted too unless another thread is bound to the same session or an open
terminal holds it; the answer
`{deleted, session_id?, session_deleted, session_kept?, session_error?}`
says which. Tasks, automations and child threads that name it keep the
stale id.
- `POST /v1/threads/{id}/fork` (`POST /v1/threads` also accepts
`parent_thread_id` naming an existing thread)
- `POST /v1/threads/{id}/read` — record the newest item a client has shown
(body `{"item_id": "<item id>"}`; see "Read markers" below)
- `GET /v1/threads/{id}/receipt` — what the thread did, one entry per action
+10
View File
@@ -395,6 +395,16 @@ surfaces = { desktop = "preview" }
since = "unreleased"
owner = "crates/tui/src/tools/tasks.rs"
[[feature]]
id = "thread-delete"
name = "Delete a thread"
summary = "DELETE /v1/threads/{id} removes an idle thread with its turns, items, events and goal, and its unshared saved session."
status = "experimental"
surfaces = { api = "preview" }
since = "unreleased"
docs = "docs/RUNTIME_API.md"
owner = "crates/tui/src/runtime_api/thread_delete.rs"
[[feature]]
id = "todos"
name = "Todo list"