feat(generation): support user cancellation of a running session#48
Open
akozak-gd wants to merge 4 commits into
Open
feat(generation): support user cancellation of a running session#48akozak-gd wants to merge 4 commits into
akozak-gd wants to merge 4 commits into
Conversation
Cancelling a generation previously just called fail_generation_session, which sent an "❌ Run Failed" notification, marked the session FAILED (indistinguishable from a real failure), and never stopped the running workflow task or its agent subprocesses. Introduce a distinct terminal CANCELLED status and make cancellation actually stop in-flight work through two mechanisms: - A process-local generation_id → asyncio.Task registry so the owning pod calls task.cancel() for immediate teardown. The task registers itself via asyncio.current_task(), so no plumbing is needed at the create_task sites (initial run, retry, and boot recovery). - Cooperative raise_if_cancelled() checks (throttled DB reads) in the orchestrator step loop, execute_all_phases, the parallel executors, and the P10Y poll loop, raising GenerationCancelledError as a cross-pod-safe fallback. The cancel endpoint sets CANCELLED first, then cancels the task, so the injected CancelledError and any GenerationCancelledError unwind against an already-terminal state and neither re-fails nor notifies. The state machine cancel() mirrors interrupted_by_shutdown(): notification-free, sets cancelled_by_user/cancelled_at markers, ends the API-key slot, and retains workspaces (Commandment II) — generated code is preserved and reclaimed by scheduled_wipe. agent_query now aclose()s the SDK query stream so the Claude Code CLI subprocess is torn down on cancel. A CANCELLED session is terminal: boot recovery and the stuck detectors already exclude it, and retry now refuses it with a clear message.
raise_if_cancelled used 0.0 as the "never checked" throttle sentinel. time.monotonic() has no fixed epoch, so on a freshly-booted host (e.g. a CI runner) the clock can be smaller than min_interval_s, making now - 0.0 < min_interval_s true and throttling the very first read. Use a None sentinel so the first check for a generation always reads.
Replace the module-level global dict + functions with a GenerationTaskRegistry class. One instance is created at app startup and stored on app.state; the run/retry/cancel endpoints receive it via a get_generation_task_registry dependency, and it is threaded into the workflow task bodies (and boot recovery) as a parameter. This removes mutable import-time global state, aligns with the codebase's dependency-injection and OOP conventions, and lets tests inject the registry via dependency_overrides instead of reaching into module internals. Behavior is unchanged: same process-local fast cancel backed by the cooperative status check.
Wire the backend cancellation (PR #48) into the Textual TUI. Detail screen (DashboardScreen): add an `x` → cancel binding, shown only while the run is active (check_action gates it to pending/initializing/ running). Confirming pops a ConfirmScreen, then calls the existing DELETE /api/v1/generation-sessions/{id} for that specific session and refreshes in place — no terminal suspend, since cancellation is silent. Sessions list: a cancelled row now renders "⊘ CANCELLED BY USER". Recognize the status everywhere it matters: add a CANCELLED pill and put "cancelled" in TERMINAL_STATUSES (so the dashboard stops polling and the header pill renders), keep the cli_service mirror in sync, announce a cancel as a neutral "Generation cancelled" desktop milestone instead of a failure, and add CANCELLED to the mcp_server GenerationStatus mirror for client/backend parity.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds user-initiated cancellation of a running generation across the whole stack. Cancelling stops all in-flight agents immediately, moves the session to a distinct terminal CANCELLED state that is never resumed on restart or retried, sends no error notification, and is exposed in the TUI on both the generation-detail and sessions-list screens.
Entrypoint
Backend:
cancel_generation_session(theDELETE /{generation_id}route) inbackend/app/api/v1/generation_sessions.py. TUI: the newx→ cancel binding and_cancel_flowonDashboardScreeninmcp_server/tui/app.py.Details
Cancellation uses two stop mechanisms: a process-local generation-id→
asyncio.Taskregistry (an injectedGenerationTaskRegistrycreated at app startup and threaded through the run/retry/cancel endpoints and boot recovery) for immediatetask.cancel()on the owning pod, plus a throttled cooperativeraise_if_cancelledcheck wired into the workflow, phase, parallel, and P10Y loops as a cross-pod-safe fallback that always runs on its first call for a generation. The endpoint setsCANCELLEDbefore cancelling the task so the injectedCancelledErrorandGenerationCancelledErrorunwind against an already-terminal state and never re-fail or notify; the state-machinecancel()mirrorsinterrupted_by_shutdown()and retains workspaces per Steel Commandment II, andagent_querynowaclose()s the SDK stream so the Claude Code CLI subprocess is torn down deterministically. The TUI recognizescancelledeverywhere it matters — a⊘ CANCELLEDpill,cancelledadded toTERMINAL_STATUSESso the dashboard stops polling, a "CANCELLED BY USER" label in the sessions list, and a neutral "Generation cancelled" desktop milestone instead of a failure. Boot recovery and the stuck detectors already excludeCANCELLED, and retry refuses it with a clear message.