Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions aai_cli/app/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import keyring.errors
import typer

from aai_cli.core import config, debuglog, env, environments, stdio, telemetry
from aai_cli.core import config, debuglog, env, environments, errors, stdio, telemetry
from aai_cli.core.environments import Environment
from aai_cli.core.errors import APIError, CLIError, NotAuthenticated
from aai_cli.ui import output, update_check
Expand Down Expand Up @@ -196,6 +196,30 @@ def _auto_login_and_exit(state: AppState, *, json_mode: bool) -> NoReturn:
)


def _run_body(
ctx: typer.Context,
fn: Callable[[AppState, bool], None],
*,
json_mode: bool,
has_deferred: bool,
) -> None:
"""Run the command body, wrapped (or not) by the telemetry/update-check machinery.

Called inside ``run_command``'s ``try`` so telemetry sees the raw CLIError (and its
error_type) before it's folded into a ``typer.Exit``. ``config path`` opts into
``has_deferred`` (a corrupt config.toml): it reports a contents-independent location,
so it runs just the body and skips the telemetry/update-check wrappers that would
re-parse the broken config.
"""
state: AppState = ctx.obj
if has_deferred:
fn(state, json_mode)
return
with telemetry.track(ctx.command_path):
fn(state, json_mode)
update_check.maybe_notify(json_mode=json_mode)


def run_command(
ctx: typer.Context,
fn: Callable[[AppState, bool], None],
Expand All @@ -219,23 +243,20 @@ def run_command(
# exit, without telemetry/update-check, both of which would just re-parse it.
_fail(deferred, json_mode=json_mode)
try:
if deferred is not None:
# `config path` opted in (tolerate_unreadable_config): it reports a
# contents-independent location, so run just the body and skip the
# telemetry/update-check wrappers that re-parse the broken config.
fn(state, json_mode)
else:
# Inside the try so telemetry sees the raw CLIError (and its error_type)
# before it's folded into a typer.Exit below.
with telemetry.track(ctx.command_path):
fn(state, json_mode)
update_check.maybe_notify(json_mode=json_mode)
_run_body(ctx, fn, json_mode=json_mode, has_deferred=deferred is not None)
except NotAuthenticated as err:
if not auto_login or not _should_auto_login(err):
_fail(err, json_mode=json_mode)
_auto_login_and_exit(state, json_mode=json_mode)
except CLIError as err:
_fail(err, json_mode=json_mode)
except KeyboardInterrupt:
# Ctrl-C (and the SIGTERM that core.signals routes through the same path) is a
# cancel, not a crash: exit 130 so `assembly … && next` composes correctly,
# instead of the bare "Aborted!" / exit 1 Click would otherwise produce. The
# interactive commands print their own "Stopped." before re-raising; this is the
# single place the cancel maps to its exit code.
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
except (typer.Exit, typer.Abort, BrokenPipeError):
# Deliberate control flow (and the closed-pipe contract handled in main.run);
# these must reach Click/the entry point untouched.
Expand Down
5 changes: 4 additions & 1 deletion aai_cli/commands/agent/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from aai_cli.agent.voices import VOICE_NAMES
from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt
from aai_cli.app.context import AppState
from aai_cli.core import choices, client, signals
from aai_cli.core import choices, client, errors, signals
from aai_cli.core.errors import UsageError
from aai_cli.streaming.session import resolve_output_modes
from aai_cli.streaming.sources import FileSource
Expand Down Expand Up @@ -135,7 +135,10 @@ def run_agent(opts: AgentOptions, state: AppState, *, json_mode: bool) -> None:
with signals.terminate_as_interrupt():
run_session(api_key, renderer=renderer, player=player, mic=audio, config=run_config)
except KeyboardInterrupt:
# Ctrl-C (or a supervisor's SIGTERM) ends the session cleanly, then exits 130
# (cancel) so the interrupt isn't reported to a caller as success.
renderer.stopped()
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
except BrokenPipeError as exc:
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
raise typer.Exit(code=0) from exc
Expand Down
5 changes: 4 additions & 1 deletion aai_cli/commands/agent_cascade/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from aai_cli.agent_cascade.config import DEFAULT_MAX_HISTORY, CascadeConfig
from aai_cli.app.agent_shared import resolve_system_prompt as _resolve_system_prompt
from aai_cli.app.context import AppState
from aai_cli.core import choices, client, config_builder, llm, signals
from aai_cli.core import choices, client, config_builder, errors, llm, signals
from aai_cli.core.errors import UsageError
from aai_cli.streaming import turn_presets
from aai_cli.streaming.session import resolve_output_modes
Expand Down Expand Up @@ -218,7 +218,10 @@ def run_agent_cascade(opts: AgentCascadeOptions, state: AppState, *, json_mode:
with signals.terminate_as_interrupt():
engine.run_cascade(renderer=renderer, player=player, config=config, deps=deps)
except KeyboardInterrupt:
# Ctrl-C (or a supervisor's SIGTERM) ends the cascade cleanly, then exits 130
# (cancel) so the interrupt isn't reported to a caller as success.
renderer.stopped()
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
except BrokenPipeError as exc:
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
raise typer.Exit(code=0) from exc
Expand Down
10 changes: 7 additions & 3 deletions aai_cli/commands/dictate/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@

from dataclasses import dataclass

import typer

from aai_cli.app.context import AppState
from aai_cli.core import choices, stdio, sync_stt
from aai_cli.core import choices, errors, stdio, sync_stt
from aai_cli.core.config_builder import split_csv
from aai_cli.core.hotkey import CTRL_C, CTRL_D, ESC, TerminalKeys
from aai_cli.core.microphone import MicrophoneSource
Expand Down Expand Up @@ -222,5 +224,7 @@ def run_dictate(opts: DictateOptions, state: AppState, *, json_mode: bool) -> No
)
_session(keys, api_key, opts, state, json_mode=json_mode, single=single)
except KeyboardInterrupt:
# Ctrl-C is the normal "done dictating" signal: end cleanly, not as an error.
return
# Ctrl-C cancels dictation, so it exits 130 (cancel) — distinct from `q`, which
# ends the session normally (exit 0). The with-block above already restored the
# terminal on the way out.
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
11 changes: 6 additions & 5 deletions aai_cli/commands/llm/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
from dataclasses import dataclass
from pathlib import Path

import typer
from rich.markup import escape

from aai_cli.app.context import AppState
from aai_cli.core import choices, client, stdio
from aai_cli.core import choices, client, errors, stdio
from aai_cli.core import llm as gateway
from aai_cli.core.errors import UsageError
from aai_cli.ui import output
Expand Down Expand Up @@ -161,16 +162,16 @@ def ask(transcript_text: str) -> str:
return gateway.content_of(response)

transcript: list[str] = []
interrupted = False
with FollowRenderer(json_mode=json_mode) as render:
# Ctrl-C is the normal "stop watching" signal -> exit cleanly (code 0).
# Ctrl-C is the normal "stop watching" signal: exit 130 (cancel) rather than
# masquerading as a clean finish — the renderer's panel closes on the way out.
try:
for turn in stdio.iter_piped_stdin_lines():
transcript.append(turn)
render(ask("\n".join(transcript)), len(transcript))
except KeyboardInterrupt:
interrupted = True
if not transcript and not interrupted:
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
if not transcript:
# An empty pipe (`assembly llm -f "…" </dev/null`) would otherwise exit 0
# silently, having asked nothing.
raise UsageError(_FOLLOW_STDIN_MESSAGE)
Expand Down
7 changes: 4 additions & 3 deletions aai_cli/commands/share/_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from aai_cli.app.context import AppState
from aai_cli.core import env as os_env
from aai_cli.core import errors
from aai_cli.core.errors import CLIError
from aai_cli.init import devserver, procfile, runner, tunnel
from aai_cli.ui import output, steps
Expand Down Expand Up @@ -86,9 +87,9 @@ def run_share(opts: ShareOptions, state: AppState, *, json_mode: bool) -> None:
output.emit(payload, _render_share, json_mode=json_mode)
server.wait()
except KeyboardInterrupt:
# Ctrl-C is the expected way to stop a foreground share; the finally
# block below tears down the tunnel and server.
pass
# Ctrl-C is the expected way to stop a foreground share: tear down (finally,
# below) then exit 130 (cancel) so it isn't reported to a caller as success.
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
finally:
tunnel.terminate(proxy)
tunnel.terminate(server)
Expand Down
8 changes: 4 additions & 4 deletions aai_cli/commands/webhooks/_listen.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from __future__ import annotations

import contextlib
import json
import threading
from collections.abc import Callable
Expand Down Expand Up @@ -180,9 +179,10 @@ def run_listen(opts: ListenOptions, *, json_mode: bool) -> None:
"check it for errors.",
)
_announce(public_url, port, json_mode=json_mode)
# Ctrl-C is the expected way to stop a foreground listener.
with contextlib.suppress(KeyboardInterrupt):
server.serve_forever()
# Ctrl-C is the expected way to stop a foreground listener; let it propagate so
# the command exits 130 (cancel). The finally below still closes the socket and
# tears down the tunnel.
server.serve_forever()
finally:
# Close here, not via `with server:` — a tunnel failure raises before the
# serve block, and the bound listening socket must not outlive the command.
Expand Down
6 changes: 6 additions & 0 deletions aai_cli/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@

from aai_cli.core import jsonshape

# The conventional Unix "cancelled by Ctrl-C" code (128 + SIGINT). Not a CLIError
# exit_code — a cancel isn't a failure with a machine-readable error type — but the
# single source of truth scripts can rely on for "the user (or a supervisor's SIGTERM,
# routed through the same clean-stop path) interrupted the command".
CANCELLED_EXIT_CODE = 130


class CLIError(Exception):
"""Base error carrying an exit code, a machine-readable type, and an optional
Expand Down
4 changes: 2 additions & 2 deletions aai_cli/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

`stream` (and the other realtime commands) treat Ctrl-C as a clean "user stopped"
signal: SIGINT arrives as a ``KeyboardInterrupt`` that the streaming lifecycle catches
to flush its closing state and exit 0. This module lets an *external* stop reach that
same path — see ``terminate_as_interrupt``.
to flush its closing state and exit 130 (the cancel code). This module lets an
*external* stop reach that same path — see ``terminate_as_interrupt``.
"""

from __future__ import annotations
Expand Down
28 changes: 22 additions & 6 deletions aai_cli/core/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import typer

from aai_cli import __version__
from aai_cli.core import argscan, config, env, procs
from aai_cli.core import argscan, config, env, errors, procs
from aai_cli.core.errors import CLIError

ENV_DISABLED = "AAI_TELEMETRY_DISABLED"
Expand Down Expand Up @@ -236,14 +236,25 @@ def _safe_dispatch(
return


def _exit_outcome(code: int) -> str:
"""Map a ``typer.Exit`` code to a telemetry outcome: 0 succeeds, 130 is a cancel
(the Ctrl-C path that exits via ``typer.Exit``), anything else is an error."""
if code == 0:
return "success"
if code == errors.CANCELLED_EXIT_CODE:
return "cancelled"
return "error"


@contextmanager
def track(command: str) -> Generator[None]:
"""Record one command run, deriving the outcome from whatever escapes the body.

CLIErrors keep their machine-readable ``error_type`` as the outcome; a
deliberate ``typer.Exit`` maps through its code; anything else is the
catch-all ``internal_error``. The body's exception always re-raises —
tracking observes control flow, never alters it.
deliberate ``typer.Exit`` maps through its code (a 130 reads as ``cancelled``);
a Ctrl-C is ``cancelled``; anything else is the catch-all ``internal_error``.
The body's exception always re-raises — tracking observes control flow, never
alters it.
"""
if not is_enabled():
yield
Expand All @@ -263,8 +274,13 @@ def track(command: str) -> Generator[None]:
raise
except typer.Exit as exc:
code = exc.exit_code
outcome = "success" if code == 0 else "error"
_safe_dispatch(command, started, outcome=outcome, exit_code=code)
_safe_dispatch(command, started, outcome=_exit_outcome(code), exit_code=code)
raise
except KeyboardInterrupt:
# A Ctrl-C that reached here (a command with no interactive handler) is a
# cancel, not an internal_error — record it as such so it doesn't inflate the
# crash rate, then let run_command map it to exit 130.
_safe_dispatch(command, started, outcome="cancelled", exit_code=errors.CANCELLED_EXIT_CODE)
raise
except BaseException as exc:
_safe_dispatch(
Expand Down
10 changes: 6 additions & 4 deletions aai_cli/init/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import webbrowser
from pathlib import Path

from aai_cli.core.errors import CLIError
from aai_cli.core.errors import CANCELLED_EXIT_CODE, CLIError
from aai_cli.ui import output


Expand Down Expand Up @@ -143,8 +143,10 @@ def run_server(
) -> int:
"""Run a prebuilt server command, wait for the port, open the browser, block until Ctrl-C.

Returns the process exit code (0 on a clean Ctrl-C shutdown). `env=None` inherits
the current environment; pass a full dict (e.g. `{**os.environ, "PORT": ...}`) to override.
Returns the process exit code: the cancel code (130) on a Ctrl-C shutdown so a
`dev && next` chain doesn't proceed past the interrupt, else the child's own code.
`env=None` inherits the current environment; pass a full dict (e.g.
`{**os.environ, "PORT": ...}`) to override.
"""
proc = subprocess.Popen(command, cwd=target, env=env)
try:
Expand All @@ -154,7 +156,7 @@ def run_server(
except KeyboardInterrupt:
proc.terminate()
proc.wait()
return 0
return CANCELLED_EXIT_CODE
return proc.returncode


Expand Down
21 changes: 13 additions & 8 deletions aai_cli/streaming/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import typer

from aai_cli.core import choices, client, config_builder, llm
from aai_cli.core import choices, client, config_builder, errors, llm
from aai_cli.core.errors import (
APIError,
CLIError,
Expand Down Expand Up @@ -327,8 +327,8 @@ def stream_one(

def _guarded(self, work: Callable[[], None], *, handle_interrupt: bool = True) -> None:
"""Run a streaming body with the shared lifecycle handling: enter the
FollowRenderer's live panel if present, treat Ctrl-C as a clean stop, exit 0 on
a closed downstream pipe, and always close the renderer.
FollowRenderer's live panel if present, treat Ctrl-C as a clean stop (exit 130),
exit 0 on a closed downstream pipe, and always close the renderer.

``handle_interrupt=False`` lets a Ctrl-C or a closed pipe propagate instead of
being swallowed here — the batch driver owns those signals across the whole
Expand All @@ -352,10 +352,13 @@ def _guarded(self, work: Callable[[], None], *, handle_interrupt: bool = True) -
except KeyboardInterrupt:
if not handle_interrupt:
raise
# Ctrl-C is a normal "user stopped" signal -> exit 0.
# Ctrl-C is a clean "user stopped" signal: flush the closing state, print
# "Stopped.", then exit 130 (the cancel code) so a `stream && next` chain
# doesn't treat the interrupt as success.
if self.follow is None:
self.renderer.close()
self.renderer.stopped()
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
except BrokenPipeError:
if not handle_interrupt:
raise
Expand Down Expand Up @@ -441,8 +444,9 @@ def stream_batch_sources(
as a per-source failure so the batch carries on — except ``NotAuthenticated``, which
re-raises to abort the whole batch (one rejected key fails every source identically).

A Ctrl-C or a closed downstream pipe stops the batch cleanly (exit 0). When any
source failed, raises a ``CLIError`` at the end so a script can trust the exit code.
A Ctrl-C stops the batch with the cancel code (exit 130); a closed downstream pipe
stops it quietly (exit 0). When any source failed, raises a ``CLIError`` at the end
so a script can trust the exit code.
"""
total = len(sources)
failures: list[str] = []
Expand All @@ -458,9 +462,10 @@ def stream_batch_sources(
failures.append(source)
output.emit_warning(f"{source}: {exc.message}", json_mode=json_mode)
except KeyboardInterrupt:
# One Ctrl-C stops the whole batch, not just the current source -> exit 0.
# One Ctrl-C stops the whole batch, not just the current source. Exit 130
# (cancel) so the interrupt isn't mistaken for a clean run of every source.
renderer.stopped()
return
raise typer.Exit(code=errors.CANCELLED_EXIT_CODE) from None
except BrokenPipeError:
# Downstream consumer (e.g. `| head`) closed the pipe; stop quietly.
raise typer.Exit(code=0) from None
Expand Down
6 changes: 5 additions & 1 deletion tests/test_agent_cascade_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,11 @@ def boom(**kwargs):
raise KeyboardInterrupt

rendered = _wire_run(monkeypatch, boom)
run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False)
# Ctrl-C ends the cascade cleanly (Stopped., renderer closed) but exits 130 (cancel),
# not success, so a caller can tell an interrupt from a normal finish.
with pytest.raises(typer.Exit) as exc:
run_agent_cascade(_opts(source="clip.wav"), AppState(), json_mode=False)
assert exc.value.exit_code == 130
assert rendered["r"].stopped_called is True
assert rendered["r"].closed is True

Expand Down
Loading
Loading