fix(engine): end a conversation's terminal sessions when it is deleted

The Runtime thread delete path now kills and deregisters every live PTY
owned by that thread and marks its durable record cancelled. The desktop
tab-close half and the jobs half remain open.

Local: cargo check -p codewhale-engine --all-targets (9m06s, exit 0).
This commit is contained in:
CodeWhale Bot committed 2026-11-08 23:14:07 -08:00
1 parent 13858fa8d0
commit 34ea11e0a3
3 files changed
+48 -4

No files matched your search

+5 -4
View File
@@ -20,10 +20,10 @@ compatibility in 0.10.3; the final tag waits for that full list. The near-term
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
tag. Items moved to 0.10.4 by name are marked below.
- **Terminal lifetime (0.10.3 work item):** deleting a conversation must end that
conversation's terminal shells and jobs. The source candidate still runs them
until the Engine exits; shells survive quitting or updating the app, and an
Engine restart loses live shells (reported as a new terminal `instance`).
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
terminal sessions owned by that Runtime thread. The desktop tab-close half and
the jobs half remain; shells still survive quitting or updating the app, and
an Engine restart loses live shells (reported as a new terminal `instance`).
Scrollback beyond the 512 KiB ring is not recoverable.
- **Windows suite (0.10.3 work item):** the Windows Test job is not green on the
current candidate. Scripted journeys are being brought to zero on GitHub-hosted
@@ -401,6 +401,7 @@ tag. Items moved to 0.10.4 by name are marked below.
### Fixed
- **Deleting a conversation ends its terminal sessions.** A Runtime thread's terminal PTYs are killed and removed from the live registry when the thread is deleted; the durable record is marked cancelled. The desktop tab-close half and the jobs half remain separate work.
- **A question waits for you.** An answerable prompt from the desktop Runtime API no longer hits the 30-minute durable-task human-wait cap; it waits until you answer or cancel. Headless and unanswerable tasks keep the fail-safe cap.
- **A plugin refused for its runtime snapshot now says why.** The warning and the enable error carry the reason the saved snapshot was rejected (for example the stage root is not read-only, or its content identity differs) instead of only "no staged snapshot". On Windows, 119 of the 135 hosted test failures were this one message with the cause discarded, so the next run can name it. Not a fix for those failures.
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
@@ -7363,6 +7363,15 @@ impl RuntimeThreadManager {
Ok((thread, evicted))
})?;
drop(active);
if let Err(error) =
crate::tools::terminal_session::kill_owner_sessions(&thread.workspace, thread_id)
{
tracing::warn!(
thread_id = %thread_id,
error = %error,
"failed to end terminal sessions for deleted thread"
);
}
if let Some(engine) = evicted {
engine.cancel_with_reason(crate::core::engine::CancelReason::Internal);
let _ = engine.try_send(Op::Shutdown);
@@ -655,6 +655,40 @@ pub fn kill_session(session: &mut TerminalSession) -> Result<(), String> {
.map_err(|e| format!("PTY kill failed: {e}"))
}
#[cfg(unix)]
pub fn kill_owner_sessions(workspace: &Path, owner: &str) -> Result<usize, String> {
let workspace = canonical(workspace);
let mut registry = sessions()
.lock()
.map_err(|_| "terminal session registry lock poisoned".to_string())?;
let keys = registry
.keys()
.filter(|key| key.workspace == workspace && key.owner == owner)
.cloned()
.collect::<Vec<_>>();
let mut ended = 0;
for key in keys {
let Some(registered) = registry.remove(&key) else {
continue;
};
let mut session = registered
.session
.lock()
.map_err(|_| "terminal session lock poisoned".to_string())?;
let _ = kill_session(&mut session);
session.durable.state = DurableTerminalState::Canceled;
session.durable.updated_at = chrono::Utc::now().to_rfc3339();
let _ = persist_durable(&session.durable_path, &session.durable);
ended += 1;
}
Ok(ended)
}
#[cfg(not(unix))]
pub fn kill_owner_sessions(_workspace: &std::path::Path, _owner: &str) -> Result<usize, String> {
Ok(0)
}
/// Resolve a live session by the identity it was issued, never creating one.
///
/// HTTP clients attach to shells the Engine already owns (the agent's