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
25 changes: 23 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,30 @@ next actionable task before working on a subsystem.
into its function's normal fall-through, never early-`return` past cleanup —
and a real failure collected before the cancel always outranks it. MCP `job_cancel` cancels the job's token, waits a short grace for a
cooperative stop, then hard-aborts the worker — its reply distinguishes the
two and never claims to cancel a job that had already finished. A flow that
two and never claims to cancel a job that had already finished. The REPL is
the seam's second producer: a Ctrl-C *during* a command (the terminal is
cooked then, so it is a real SIGINT rather than reedline's key event) is
forwarded onto the session token instead of killing the process, and a second
press force-exits 130 with a warning about the locks that may be left behind.
It installs a **fresh token per dispatched line, unconditionally** — the token
is one-shot, so a cancelled one left in place would kill every later dispatch
at the pre-flight check, `quit`'s teardown included, stranding exactly the
locks the cancel had to release. The teardown dispatch gets the fresh token but
**no cancel arm**: a press there only escalates, because cancelling the
cleanup is what strands the locks. That is a property of the *dispatch*, not of
the key — a typed `quit`/`exit` is routed to it by resolving the line's command
position through the registry, so it is protected exactly as Ctrl-D is.
A new interrupt hook belongs on this seam,
never on its own `tokio::signal::ctrl_c` — the REPL arms SIGINT process-wide
from the first prompt onward (startup seeding is still outside that window),
and a headless tool call has no terminal to press Ctrl-C at, while a stdio
server that *does* share one would fire every listener at once, interrupting
work nobody asked to stop. A flow that
stops on a cancel must surface as `CommandError::Cancelled`, not a generic
failure — and that verdict must come from the flow's own `cancelled` flag,
failure — unless stopping *is* its documented success (`request_review
--watch` returns `Ok` with "the request is still posted"; `regenerate`
abandons the wait and reports the state it last saw, since the server keeps
building) — and that verdict must come from the flow's own `cancelled` flag,
never from sniffing the session token, which would mask a real host failure
that merely coincided with a cancel (see
`commands/perform.rs::map_flow_error`). New long-running command bodies
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,28 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
loaded template. A *cooperative* cancel is unchanged: a body that unwound
through its own flow ran its own unlock discipline. Nothing else is done at
the hosts; as before, the remote command may still be running there.
- Ctrl-C during a running command no longer kills the REPL outright (#441).
Killing it skipped every teardown, so each locked host was left with a
dead-pid `/var/lock/mtui.lock` that blocked the next tester. The first press
now cancels the command cooperatively — `update`, `prepare`, `downgrade`, and
fan-outs stop at their next checkpoint with their locks released, while
`install`/`uninstall`/`run` finish the host operation already under way — and
the session stays usable afterwards. A second press still force-quits (exit
130), now naming `unlock --force` as the remedy for whatever locks it strands
— and without discarding the session's command history, as the old kill did.
During the session teardown (Ctrl-D or a typed `quit`/`exit`) a press cannot
cancel anything, because the teardown is what releases the pool claims; two
presses still force-quit, so a blackholed refhost can no longer wedge the
exit, and that message names `unlock --pool` as well.
Ctrl-C at the prompt is unchanged (it clears the line), and Ctrl-C during the
initial `-a`/`-k`/`--sut` load still exits immediately without teardown.
Two existing waits are folded onto the same seam: `request_review --watch`
stops on a cancel (and now also on an MCP `job_cancel`, which could never
interrupt it before), and `regenerate`'s wait for TeReGen finally honours the
cooperative-stop hook it always advertised. This also removes a
non-determinism: `request_review --watch` used to arm the process-wide SIGINT
handler as a side effect, after which Ctrl-C was silently swallowed for the
rest of the session.

### Security

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/mtui-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ mtui-types.workspace = true
# startup seeding path (before the REPL loop begins).
mtui-testreport.workspace = true
tokio.workspace = true
# `CancellationToken`: the REPL installs a fresh one per dispatched line so a
# mid-command Ctrl-C cancels *that* command's flow (and only it).
tokio-util.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# Optional desktop-notification backend (feature `notify`); a headless no-op
Expand Down
2 changes: 1 addition & 1 deletion crates/mtui-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ pub mod startup;

pub use notification::notify_user;
pub use prompt::MtuiPrompt;
pub use repl::Repl;
pub use repl::{Repl, ReplExit};
pub use startup::seed_session;

use std::io::Write;
Expand Down
17 changes: 16 additions & 1 deletion crates/mtui-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,20 @@ fn main() -> anyhow::Result<()> {
let session = Arc::new(Mutex::new(session));
let mut repl = Repl::new(registry, session);

runtime.block_on(repl.run())
let ending = runtime.block_on(repl.run())?;
let Some(status) = ending.status() else {
return Ok(());
};
// Force-quit: run exactly one destructor, then leave. reedline persists its
// `FileBackedHistory` on drop and `std::process::exit` runs none, so the
// history would be lost; but dropping the whole `Repl` would synchronously
// tear down the entire session graph on the one path whose job is to get
// out now — `into_line_editor` keeps the blast radius at the editor.
//
// `process::exit`, not `main() -> ExitCode`: returning would drop the
// runtime, which blocks on in-flight `spawn_blocking` — including the stdin
// prompter that is very likely outstanding when someone force-quits (its
// `keep waiting? [Y/n]` read never returns, so neither would the drop).
drop(repl.into_line_editor());
std::process::exit(status.into());
}
Loading