mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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 at53ae15645c): 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:
1 parent
53ae15645c
commit
8f54845f78
4 files changed
+65
-3
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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?;
|
||||
|
||||
Reference in new issue
Block a user