Skip to content

feat(generation): support user cancellation of a running session#48

Open
akozak-gd wants to merge 4 commits into
mainfrom
feat/cancel-generation-workflow
Open

feat(generation): support user cancellation of a running session#48
akozak-gd wants to merge 4 commits into
mainfrom
feat/cancel-generation-workflow

Conversation

@akozak-gd

@akozak-gd akozak-gd commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

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 (the DELETE /{generation_id} route) in backend/app/api/v1/generation_sessions.py. TUI: the new x → cancel binding and _cancel_flow on DashboardScreen in mcp_server/tui/app.py.

Details

Cancellation uses two stop mechanisms: a process-local generation-id→asyncio.Task registry (an injected GenerationTaskRegistry created at app startup and threaded through the run/retry/cancel endpoints and boot recovery) for immediate task.cancel() on the owning pod, plus a throttled cooperative raise_if_cancelled check 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 sets CANCELLED before cancelling the task so the injected CancelledError and GenerationCancelledError unwind against an already-terminal state and never re-fail or notify; the state-machine cancel() mirrors interrupted_by_shutdown() and retains workspaces per Steel Commandment II, and agent_query now aclose()s the SDK stream so the Claude Code CLI subprocess is torn down deterministically. The TUI recognizes cancelled everywhere it matters — a ⊘ CANCELLED pill, cancelled added to TERMINAL_STATUSES so 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 exclude CANCELLED, and retry refuses it with a clear message.

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.
@akozak-gd akozak-gd marked this pull request as ready for review July 14, 2026 09:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant