Skip to content

Commit e6bc347

Browse files
luzeyang (INT)h3c-hexin
authored andcommitted
feat(engine): withdraw_steer reports SteerWithdrawal outcome
Hosts that re-send a queued input through another path (interrupt-and-send) must know whether the engine copy can still be committed, otherwise the same message can be delivered twice: the withdrawal used to be fire-and-forget, and "no SteerCommitted received yet" cannot be distinguished from "already committed" on the async event path. SteerControlState::withdraw now reports from the authoritative unsettled ledger, and EngineHandle::withdraw_steer returns it: - Retired: the id was pending and is now marked withdrawn — guaranteed never to be injected, settles with exactly one SteerDropped; the host may safely re-send. - NotPending: the id already settled (committed or dropped) or was never seen — no-op with no event; the host must not re-send. The withdrawal-set invariants are unchanged: unknown ids do not grow the set (bounded), settled ids stay no-ops. Behavior test steer_lifecycle_withdrawal_is_bounded_and_prevents_commit now asserts all three outcomes. No-Issue: driven by pinvou-agent#308 review (interrupt-and-send resend race); upstream-neutral, same area as the conversation-insertion primitives in #16. Signed-off-by: luzeyang (INT) <lu.zeyang@h3c.com>
1 parent 4831c37 commit e6bc347

3 files changed

Lines changed: 50 additions & 14 deletions

File tree

‎crates/tui/src/core/engine.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,21 @@ pub enum CancelMode {
579579
StopDropInbox,
580580
}
581581

582+
/// Outcome of withdrawing a queued steer by id. Hosts that re-send the same
583+
/// input through another path (e.g. interrupt-and-send) need to know whether
584+
/// the engine copy can still be committed, otherwise the same message may be
585+
/// delivered twice.
586+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587+
pub enum SteerWithdrawal {
588+
/// The steer was still pending and is now marked withdrawn: it will never
589+
/// be injected and settles with exactly one `Event::SteerDropped`.
590+
Retired,
591+
/// The id was not pending — already committed, already settled, or never
592+
/// seen. The withdrawal is a no-op and the engine copy may already be in
593+
/// the transcript; hosts must not re-send in that case.
594+
NotPending,
595+
}
596+
582597
impl CancelReason {
583598
fn describe(self) -> &'static str {
584599
match self {
@@ -665,9 +680,15 @@ impl SteerControlState {
665680
self.withdrawn.remove(id);
666681
}
667682

668-
fn withdraw(&mut self, id: &str) {
683+
fn withdraw(&mut self, id: &str) -> SteerWithdrawal {
669684
if self.unsettled.contains_key(id) {
670685
self.withdrawn.insert(id.to_string());
686+
SteerWithdrawal::Retired
687+
} else {
688+
// Unknown ids must not grow the withdrawal set (bounded), and an
689+
// id that already settled (committed or dropped) must stay a
690+
// no-op so a late withdraw cannot rewrite history.
691+
SteerWithdrawal::NotPending
671692
}
672693
}
673694

‎crates/tui/src/core/engine/handle.rs‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -271,19 +271,22 @@ impl EngineHandle {
271271

272272
/// Withdraw a queued steer before the engine injects it.
273273
///
274-
/// Fire-and-forget: the id is recorded in a set shared with the engine,
275-
/// which checks it at every steer collection and injection point. A
276-
/// withdrawn steer is never appended to the transcript; when the engine
277-
/// next encounters it, the steer is skipped and reported once via
278-
/// `Event::SteerDropped`. Withdrawing an id that was already committed —
279-
/// or never existed — is a no-op with no event. The mark survives across
280-
/// turns (a parked steer may only surface in a later turn) and is cleared
281-
/// on session switch and shutdown.
282-
pub fn withdraw_steer(&self, steer_id: &str) {
274+
/// The id is recorded in a set shared with the engine, which checks it at
275+
/// every steer collection and injection point. A withdrawn steer is never
276+
/// appended to the transcript; when the engine next encounters it, the
277+
/// steer is skipped and reported once via `Event::SteerDropped`.
278+
///
279+
/// Returns [`SteerWithdrawal::Retired`] when the id was still pending and
280+
/// is now guaranteed never to be injected, or
281+
/// [`SteerWithdrawal::NotPending`] when the id already settled (committed
282+
/// or dropped) or was never seen — a no-op with no event. Hosts that
283+
/// re-send the same input through another path must check this outcome to
284+
/// avoid delivering the message twice.
285+
pub fn withdraw_steer(&self, steer_id: &str) -> crate::core::engine::SteerWithdrawal {
283286
self.steer_control
284287
.lock()
285288
.unwrap_or_else(std::sync::PoisonError::into_inner)
286-
.withdraw(steer_id);
289+
.withdraw(steer_id)
287290
}
288291

289292
/// Steer an in-flight turn with additional user input.

‎crates/tui/src/core/engine/tests.rs‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5385,7 +5385,11 @@ async fn steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
53855385
&Config::default(),
53865386
);
53875387
let turn = engine.begin_steer_turn();
5388-
handle.withdraw_steer("never-existed");
5388+
assert_eq!(
5389+
handle.withdraw_steer("never-existed"),
5390+
crate::core::engine::SteerWithdrawal::NotPending,
5391+
"unknown ids report NotPending"
5392+
);
53895393
assert!(
53905394
engine
53915395
.steer_control
@@ -5397,10 +5401,18 @@ async fn steer_lifecycle_withdrawal_is_bounded_and_prevents_commit() {
53975401
);
53985402

53995403
let steer_id = handle.steer("withdraw this").await.expect("queue steer");
5400-
handle.withdraw_steer(&steer_id);
5404+
assert_eq!(
5405+
handle.withdraw_steer(&steer_id),
5406+
crate::core::engine::SteerWithdrawal::Retired,
5407+
"pending steer reports Retired"
5408+
);
54015409
let steer = engine.rx_steer.recv().await.expect("queued steer");
54025410
assert!(!engine.inject_steer(steer).await);
5403-
handle.withdraw_steer(&steer_id);
5411+
assert_eq!(
5412+
handle.withdraw_steer(&steer_id),
5413+
crate::core::engine::SteerWithdrawal::NotPending,
5414+
"settled id reports NotPending and stays a no-op"
5415+
);
54045416
let state = engine
54055417
.steer_control
54065418
.lock()

0 commit comments

Comments
 (0)