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
26 changes: 25 additions & 1 deletion crates/tui/src/core/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,24 @@ pub enum CancelMode {
StopDropInbox,
}

/// Outcome of withdrawing a queued steer by id. Hosts that re-send the same
/// input through another path (e.g. interrupt-and-send) need to know whether
/// the engine copy can still be committed, otherwise the same message may be
/// delivered twice.
#[must_use = "reconcile the withdrawal outcome before deciding whether to re-send"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SteerWithdrawal {
/// The steer was still pending and is now marked withdrawn: it will never
/// be injected and settles with exactly one `Event::SteerDropped`.
Retired,
/// The id was not pending — already committed, already dropped, or never
/// seen. This outcome alone does not prove whether the engine copy reached
/// the transcript. Hosts must reconcile the matching `SteerCommitted` or
/// `SteerDropped` event before deciding whether to re-send; if no terminal
/// outcome is available, delivery remains indeterminate.
NotPending,
}

impl CancelReason {
fn describe(self) -> &'static str {
match self {
Expand Down Expand Up @@ -665,9 +683,15 @@ impl SteerControlState {
self.withdrawn.remove(id);
}

fn withdraw(&mut self, id: &str) {
fn withdraw(&mut self, id: &str) -> SteerWithdrawal {
if self.unsettled.contains_key(id) {
self.withdrawn.insert(id.to_string());
SteerWithdrawal::Retired
} else {
// Unknown ids must not grow the withdrawal set (bounded), and an
// id that already settled (committed or dropped) must stay a
// no-op so a late withdraw cannot rewrite history.
SteerWithdrawal::NotPending
}
}

Expand Down
25 changes: 15 additions & 10 deletions crates/tui/src/core/engine/handle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,19 +271,24 @@ impl EngineHandle {

/// Withdraw a queued steer before the engine injects it.
///
/// Fire-and-forget: the id is recorded in a set shared with the engine,
/// which checks it at every steer collection and injection point. A
/// withdrawn steer is never appended to the transcript; when the engine
/// next encounters it, the steer is skipped and reported once via
/// `Event::SteerDropped`. Withdrawing an id that was already committed —
/// or never existed — is a no-op with no event. The mark survives across
/// turns (a parked steer may only surface in a later turn) and is cleared
/// on session switch and shutdown.
pub fn withdraw_steer(&self, steer_id: &str) {
/// The id is recorded in a set shared with the engine, which checks it at
/// every steer collection and injection point. A withdrawn steer is never
/// appended to the transcript; when the engine next encounters it, the
/// steer is skipped and reported once via `Event::SteerDropped`.
///
/// Returns [`SteerWithdrawal::Retired`] when the id was still pending and
/// is now guaranteed never to be injected, or
/// [`SteerWithdrawal::NotPending`] when the id already settled (committed
/// or dropped) or was never seen — a no-op with no new event. `NotPending`
/// does not by itself prove delivery: hosts must reconcile the matching
/// terminal event before deciding whether to re-send, and preserve an
/// indeterminate input rather than reporting successful delivery.
#[must_use = "reconcile the withdrawal outcome before deciding whether to re-send"]
pub fn withdraw_steer(&self, steer_id: &str) -> crate::core::engine::SteerWithdrawal {
self.steer_control
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.withdraw(steer_id);
.withdraw(steer_id)
}

/// Steer an in-flight turn with additional user input.
Expand Down
62 changes: 58 additions & 4 deletions crates/tui/src/core/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5378,14 +5378,18 @@ async fn steer_lifecycle_session_rejects_late_reserved_send() {
}

#[tokio::test]
async fn steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
async fn forkguard_steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
let workspace = tempdir().expect("tempdir");
let (mut engine, handle) = Engine::new(
deterministic_engine_config(workspace.path()),
&Config::default(),
);
let turn = engine.begin_steer_turn();
handle.withdraw_steer("never-existed");
assert_eq!(
handle.withdraw_steer("never-existed"),
crate::core::engine::SteerWithdrawal::NotPending,
"unknown ids report NotPending"
);
assert!(
engine
.steer_control
Expand All @@ -5397,10 +5401,18 @@ async fn steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
);

let steer_id = handle.steer("withdraw this").await.expect("queue steer");
handle.withdraw_steer(&steer_id);
assert_eq!(
handle.withdraw_steer(&steer_id),
crate::core::engine::SteerWithdrawal::Retired,
"pending steer reports Retired"
);
let steer = engine.rx_steer.recv().await.expect("queued steer");
assert!(!engine.inject_steer(steer).await);
handle.withdraw_steer(&steer_id);
assert_eq!(
handle.withdraw_steer(&steer_id),
crate::core::engine::SteerWithdrawal::NotPending,
"settled id reports NotPending and stays a no-op"
);
let state = engine
.steer_control
.lock()
Expand All @@ -5418,6 +5430,48 @@ async fn steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
assert!(rx.try_recv().is_err(), "withdrawal settles exactly once");
}

#[tokio::test]
async fn forkguard_steer_lifecycle_late_withdraw_reconciles_committed_event() {
let workspace = tempdir().expect("tempdir");
let (mut engine, handle) = Engine::new(
deterministic_engine_config(workspace.path()),
&Config::default(),
);
let turn = engine.begin_steer_turn();
let steer_id = handle.steer("commit this").await.expect("queue steer");
let steer = engine.rx_steer.recv().await.expect("queued steer");
assert!(engine.inject_steer(steer).await);
assert_eq!(
handle.withdraw_steer(&steer_id),
crate::core::engine::SteerWithdrawal::NotPending,
"a late withdrawal reports only that the id is no longer pending"
);
engine.finish_steer_turn(turn);

let mut rx = handle.rx_event.write().await;
let events: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
assert_eq!(
events
.iter()
.filter(
|event| matches!(event, Event::SteerCommitted { steer_id: id } if id == &steer_id)
)
.count(),
1,
"the terminal event is the authority that proves delivery"
);
assert_eq!(
events
.iter()
.filter(
|event| matches!(event, Event::SteerDropped { steer_id: id } if id == &steer_id)
)
.count(),
0,
"a committed steer must not also report a drop"
);
}

#[tokio::test]
async fn engine_drop_reports_unconsumed_steers_best_effort() {
let workspace = tempdir().expect("tempdir");
Expand Down
Loading