Skip to content

Stop streaming commands cleanly on SIGTERM, like Ctrl-C#194

Merged
alexkroman merged 1 commit into
mainfrom
sigterm-graceful-stop
Jun 16, 2026
Merged

Stop streaming commands cleanly on SIGTERM, like Ctrl-C#194
alexkroman merged 1 commit into
mainfrom
sigterm-graceful-stop

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

What

Make stream, agent, agent-cascade, and speak treat SIGTERM the same as Ctrl-C — a clean stop that flushes closing state and exits 0.

Why

The realtime commands already treat Ctrl-C (SIGINT → KeyboardInterrupt) as a graceful "user stopped" signal. But an external supervisor that stops the command from outside the terminal — a Hammerspoon hotkey, a service manager, a wrapper script's kill — sends SIGTERM, whose default action aborts the process before that flush runs. Delivering SIGINT instead is awkward: under a just/shell wrapper it means signalling the whole process group.

This came out of wiring assembly stream to a Hammerspoon start/stop hotkey: stopping cleanly required perl setpgrp + kill -INT -<pgid> gymnastics purely to deliver a clean SIGINT. With this change a controller just sends SIGTERM (e.g. Hammerspoon's hs.task:terminate()).

How

  • New aai_cli/core/signals.pyterminate_as_interrupt() context manager: installs a SIGTERM handler that raises KeyboardInterrupt, so SIGTERM flows through each command's existing clean-stop path. Main-thread-only (no-op off-thread, where signal.signal is forbidden) and always restores the previous handler.
  • Wrapped the interactive run of each streaming command with it: stream (single source + --from-stdin batch), agent, agent-cascade, speak.

No new flag; the behavior is transparent. SIGTERM mirrors whatever Ctrl-C already does per command (exit 0 for stream/agent/cascade; the same abort path for speak).

Tests

  • tests/test_signals.py — handler install/restore + off-main-thread no-op.
  • tests/test_stream_sigterm.py — both stream paths install the handler around the run.
  • One wiring test each added to the agent, cascade, and speak suites. Each asserts the SIGTERM handler is the interrupt-raiser while the command runs, so removing a wrapper fails the test.

Full scripts/check.sh green locally (100% patch coverage, mutation gate passed).

🤖 Generated with Claude Code

The realtime commands treat Ctrl-C (SIGINT -> KeyboardInterrupt) as a clean
"user stopped" signal that flushes closing state and exits 0. An external
supervisor that stops the command from outside the terminal -- a Hammerspoon
hotkey, a service manager, a wrapper script's `kill` -- sends SIGTERM, whose
default action aborts the process before that flush runs, and delivering SIGINT
instead means signalling the whole process group under a shell wrapper.

Add `core.signals.terminate_as_interrupt`, a context manager that re-raises
SIGTERM as KeyboardInterrupt (main-thread-only, restores the prior handler), and
wrap the interactive run of `stream` (single + --from-stdin batch), `agent`,
`agent-cascade`, and `speak` with it. SIGTERM now routes through each command's
existing clean-stop path, so a controller can stop a recording with a plain
SIGTERM. No new flag; behavior is transparent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexkroman alexkroman added this pull request to the merge queue Jun 16, 2026
Merged via the queue into main with commit 158a43d Jun 16, 2026
19 checks passed
@alexkroman alexkroman deleted the sigterm-graceful-stop branch June 16, 2026 22:27
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.

2 participants