fix(tui): settle Making room when an engine is replaced so /new is not refused forever

What
- replace_tui_engine now retires the old engine through retire_tui_engine
  before respawning. Before Op::Shutdown it cancels, through the old handle,
  the active compaction id and a manual /compact that was already sent to the
  old engine's mailbox (EngineHandle::cancel_compaction, out of band; a still
  queued Op::CompactContext is claimed as pre-cancelled). It then settles the
  UI with the existing reducer, apply_compaction_cancelled, which writes one
  transcript receipt and one Info toast: "Making room stopped; the
  conversation was not changed" (the engine's own cancel literal).
- A /compact deferred because the mailbox was full was never sent to the old
  engine. It is skipped here, so flush_deferred_manual_compaction still hands
  it to the new engine as before.
- A purge cannot be cancelled (Op::PurgeContext runs inline with no cancel
  token, and Shutdown is read only after it returns), so is_purging is
  released with a transcript receipt plus a Warning toast: "Session cleanup
  was abandoned with the replaced engine; the conversation was not changed".
  It is written as a receipt because switch_provider and switch_workspace
  overwrite status_message straight after the replacement.
- The redaction-gate confirmation rebuild in event_loop.rs, the only other
  place that retires an engine, uses the same retire_tui_engine.

Why
Engine replacement drops the old engine's event receiver, so the old pass's
CompactionCompleted/Failed/Cancelled never arrives. is_compacting,
active_compaction and manual_compaction_queued are cleared only by those
events or by recover_engine_event_disconnect, every stall watchdog skips
while is_compacting is set, and Esc/Ctrl+C cancel through the new handle.
The session_transition_blockers gate then refused /new, /new --force,
/resume and /compact until restart.

Call sites covered (9 replace_tui_engine callers plus 1 inline; line
numbers here are at 53ae15645c):
switch_provider (provider_routes.rs:789; reached from /provider, the
provider picker, the model picker on a cross-provider model, the custom
provider and setup-confirmed picker flows, Codewhale-owned and OrcaRouter
logins, and local Ollama adoption), apply_provider_fallback_switch
(apply.rs:1285), the
auth-failure provider rollback respawn (event_loop.rs:4565),
AppAction::SwitchProfile (apply.rs:2926), AppAction::PluginRegistryChanged
(apply.rs:1828), switch_workspace (session_state.rs:917),
AppAction::LoadSession (apply.rs:1593), AppAction::SyncSession for load,
fork or reset (apply.rs:1702), ViewEvent::SessionSelected session picker
restore (handlers.rs:2032), and the redaction-gate rebuild
(event_loop.rs:5458). The session load/sync/picker paths normally run after
the session-transition gate, so their markers are already false there.

What this does not do
- No conversation change from the retired engine reaches this session, but a
  turn that is still live on the retired engine keeps running until it ends
  (its events and approvals go to a receiver nobody reads). Cancelling an
  automatic pass inside such a turn lets that turn continue on the old engine.
  engine_handle.cancel() was deliberately not added: it would latch the turn
  markers behind a TurnComplete that never arrives.
- The #2982 turn-lock gap is not in this slice. switch_provider is reachable
  mid-turn without an is_loading check from AppAction::SwitchProvider
  (apply.rs:2280), ViewEvent::ProviderPickerApplied (handlers.rs:3020),
  ProviderPickerExternalConsentConfirmed (handlers.rs:3150), apply.rs:3624,
  4081 and 4163, and event_loop.rs:7437 and 7843; only the model picker
  checks (apply.rs:939), and switch_workspace checks only is_loading. A mid-turn switch therefore still strands the turn: V7
  below still refuses /new with "a cancelled turn is still settling" after
  Esc. A lock would belong at the top of switch_provider, not in one arm.
- Cancellation stops the await, not necessarily upstream billing; an
  abandoned purge still runs to completion on the old engine and may write an
  orphaned offload copy.
- The receipts are English literals, like the engine's existing compaction
  strings.
- Decision taken: a provider/profile/model switch during Making room stops
  the pass rather than being refused.
- This is a demonstrated path to the #6981 refusal, not proof it was the
  reporter's path (their transcript shows the failure line, so their first
  pass settled); the report stays open.

Verification (source = 53ae15645c + this diff; clean tree before the edit)
- Real-process A/B through the TUI in an isolated tmux socket (-L i6981b),
  scratch HOME and CODEWHALE_HOME, CODEWHALE_TELEMETRY=0, local mock gateway
  on 127.0.0.1:18983 whose non-streaming reply is HTTP 200
  {"data":null,"success":false,"message":...} (no choices), configured as
  [providers.sub2api] openai-compatible plus [providers.deepseek], both
  pointed at the mock. Base = debug build of unmodified 53ae15645c; fix =
  incremental debug build with this diff.
- V6b (8 s non-streaming delay; hi, /compact, /provider deepseek during the
  pass, /new, /new --force, Esc, /new):
  base: "Making room…", "Provider switched: sub2api → deepseek", then every
    /new and /new --force: "Error: Cannot start a new session while runtime
    work is active: • context compaction is running".
  fix: "Making room…", "Making room stopped; the conversation was not
    changed", "Provider switched: sub2api → deepseek", then /new "Started
    new session 642ceebc", /new --force and /new after Esc also start new
    sessions. Mock log: one non-streaming request, no retry on the new
    engine.
- V7 (6 s streaming turn; /compact queued behind it; /provider deepseek
  mid-turn; /new; Esc; /new):
  base: /new names "the session is still busy with the current turn",
    "the runtime reports a turn in progress", "a manual compaction is
    queued"; after Esc "a cancelled turn is still settling" and "a manual
    compaction is queued". The old engine still sent the queued compaction
    request after its turn ended (mock: stream=False at 07:38:28).
  fix: "Making room stopped; the conversation was not changed" before
    "Provider switched"; /new names only the two turn blockers, after Esc
    only "a cancelled turn is still settling" (the #2982 gap above); the
    mock received no non-streaming request.
- V1 controls on the fix build: envelope mode "Made room: 2 → 4 messages";
  error envelope "Making room failed: Chat API response missing choices" /
  "Turn failed: …" then /new "Started new session f61e78ff" (base behaves
  the same for the error envelope: "Started new session e6d26891").
- scripts/dev-test.sh tui provider_switch compaction transition_blockers
  new_session redaction_gate workspace_switch provider_fallback
  profile_switch spawn_tui_engine (nextest, codewhale-tui --lib):
  232 tests run: 232 passed, 15119 skipped.
- scripts/dev-cargo.sh clippy -p codewhale-tui --all-targets --all-features
  --locked -- -D warnings -A clippy::uninlined_format_args
  -A clippy::too_many_arguments -A clippy::unnecessary_map_or: exit 0, no
  warnings.
- cargo fmt --all -- --check: clean.
- ./scripts/sync-changelog.sh --check: up to date;
  ./scripts/release/check-versions.sh --require-dated-release: exit 0.
- npm test && npm run check:web: exit 0. root 101/101; runtime-sdk 19/19;
  extension-host 420 tests, 413 pass, 0 fail, 7 skipped; web node 7/7;
  vitest 74 files, 772/772; check:web passed (next build 994 pages).

Not verified: the reporter's actual blocker or Windows machine; a deferred
(mailbox-full) /compact across a switch and a purge across a switch were not
driven in the TUI (code-read only); whether the client actually closed the
abandoned 8 s socket (the mock logs request starts only); profile, plugin,
workspace, fallback and rollback replacements were not driven by hand; the
full workspace test suite; hosted CI. No new tests or code comments.

Refs #6981 (reported by @luestr).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-16 07:46:06 -07:00
1 parent 53ae15645c
commit 8f54845f78
4 files changed
+65 -3

No files matched your search

+15
View File
@@ -36,6 +36,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`-H "Authorization: Bearer $TOKEN"`) is not affected, and other upload
tools (scp, rsync, nc, httpie, `aws s3 cp`) and secrets inside an ordinary
file are not detected. Full Access is unchanged (Refs CW-7236).
- Switching provider, model route, profile or workspace, or changing plugins,
while Making room is running or queued no longer leaves the session stuck.
The pass on the replaced engine is stopped and the transcript says "Making
room stopped; the conversation was not changed". Before, `/new`,
`/new --force`, `/resume` and `/compact` were refused with "context
compaction is running" or "a manual compaction is queued" until Codewhale
was restarted. A `/compact` still waiting for a free engine slot is carried
to the new engine as before. A context purge caught by a switch cannot be
stopped: the replaced engine may still finish and bill its request, and the
transcript now says "Session cleanup was abandoned with the replaced engine;
the conversation was not changed" instead of blocking `/new` with "session
cleanup is running". A turn still running when the engine is replaced is not
covered and can keep `/new` refused. Thanks @luestr for reporting the
refusal in #6981; this is one way to reach it, and the report stays open
until the blocker that 0.10.2 names confirms its cause (Refs #6981).
## [0.10.2] - 2026-10-13
+15
View File
@@ -36,6 +36,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
(`-H "Authorization: Bearer $TOKEN"`) is not affected, and other upload
tools (scp, rsync, nc, httpie, `aws s3 cp`) and secrets inside an ordinary
file are not detected. Full Access is unchanged (Refs CW-7236).
- Switching provider, model route, profile or workspace, or changing plugins,
while Making room is running or queued no longer leaves the session stuck.
The pass on the replaced engine is stopped and the transcript says "Making
room stopped; the conversation was not changed". Before, `/new`,
`/new --force`, `/resume` and `/compact` were refused with "context
compaction is running" or "a manual compaction is queued" until Codewhale
was restarted. A `/compact` still waiting for a free engine slot is carried
to the new engine as before. A context purge caught by a switch cannot be
stopped: the replaced engine may still finish and bill its request, and the
transcript now says "Session cleanup was abandoned with the replaced engine;
the conversation was not changed" instead of blocking `/new` with "session
cleanup is running". A turn still running when the engine is replaced is not
covered and can keep `/new` refused. Thanks @luestr for reporting the
refusal in #6981; this is one way to reach it, and the report stays open
until the blocker that 0.10.2 names confirms its cause (Refs #6981).
## [0.10.2] - 2026-10-13
+34 -1
View File
@@ -508,9 +508,42 @@ fn spawn_tui_engine(
handle
}
async fn replace_tui_engine(app: &mut App, api_config: &Config, engine_handle: &mut EngineHandle) {
async fn retire_tui_engine(
app: &mut App,
engine_handle: &EngineHandle,
) -> Option<crate::mcp::McpHandoff> {
let mcp_handoff = engine_handle.retire_mcp_for_handoff();
let active = app
.active_compaction
.as_ref()
.map(|active| (active.id.clone(), active.auto));
let queued = (app.manual_compaction_queued && app.deferred_manual_compaction.is_none())
.then(|| app.manual_compaction_id.clone().unwrap_or_default());
for id in active.iter().map(|(id, _)| id).chain(queued.iter()) {
let _ = engine_handle.cancel_compaction(id.clone());
}
let _ = engine_handle.send(Op::Shutdown).await;
if active.is_some() || queued.is_some() {
let (id, auto) = active.unwrap_or_else(|| (queued.unwrap_or_default(), false));
app.manual_compaction_queued = false;
app.manual_compaction_id = None;
apply_compaction_cancelled(
app,
&id,
auto,
"Making room stopped; the conversation was not changed".to_string(),
);
}
if std::mem::take(&mut app.is_purging) {
let text = "Session cleanup was abandoned with the replaced engine; the conversation was not changed".to_string();
add_compaction_receipt(app, &text);
set_explicit_compaction_status(app, text, StatusToastLevel::Warning, false);
}
mcp_handoff
}
async fn replace_tui_engine(app: &mut App, api_config: &Config, engine_handle: &mut EngineHandle) {
let mcp_handoff = retire_tui_engine(app, engine_handle).await;
*engine_handle = spawn_tui_engine(app, api_config, mcp_handoff);
}
+1 -2
View File
@@ -5455,8 +5455,7 @@ pub(crate) async fn run_event_loop(
// The engine already spawned with masking on
// (the unconfirmed safe default). Rebuild it so
// its client picks up the confirmed opt-out.
let mcp_handoff = engine_handle.retire_mcp_for_handoff();
let _ = engine_handle.send(Op::Shutdown).await;
let mcp_handoff = retire_tui_engine(app, &engine_handle).await;
engine_handle =
spawn_tui_engine_with_session(app, config, mcp_handoff)
.await?;