mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 01:48:20 -10:00
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:
1 parent
7a27498a8d
commit
6793371e4e
9 files changed
+170
-8
No files matched your search
+2
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user