Repository navigation
fix(ws-13): ack drained inputs before cancel exit - #3686
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a mechanism to ensure that pending input acknowledgments are processed when the agent loop is cancelled. It adds a new helper function, checkpoint_and_exit_if_cancelled_after_pending_input_ack, which handles final checkpoints and input acks before exiting, and updates several call sites within the executor. New tests verify that acks are flushed after steering and followup drains. Feedback indicates that the new helper function duplicates existing logic and missed a cancellation boundary at the start of the loop, which could still lead to unacknowledged inputs. It is recommended to merge the functions to ensure consistent behavior across all cancellation points.
| async fn checkpoint_and_exit_if_cancelled_after_pending_input_ack( | ||
| &self, | ||
| host: &(dyn AgentLoopDriverHost + Send + Sync), | ||
| state: LoopExecutionState, | ||
| pending_input_ack: &mut PendingInputAck, | ||
| ) -> Result<CancelCheck, AgentLoopExecutorError> { | ||
| let Some(signal) = host.observe_cancellation() else { | ||
| return Ok(CancelCheck::Continue(Box::new(state))); | ||
| }; | ||
|
|
||
| let fallback_state = state.clone(); | ||
| match self.checkpoint(host, state, CheckpointKind::Final).await { | ||
| Ok(checked) => { | ||
| pending_input_ack.ack(host).await?; | ||
| Ok(CancelCheck::Exit(cancelled_exit( | ||
| host, | ||
| checked.state, | ||
| Some(checked.checkpoint_id), | ||
| cancelled_reason_from_signal(&signal), | ||
| )?)) | ||
| } | ||
| Err(_) | ||
| if !host | ||
| .run_context() | ||
| .resolved_run_profile | ||
| .checkpoint_policy | ||
| .require_final_checkpoint => | ||
| { | ||
| Ok(CancelCheck::Exit(cancelled_exit( | ||
| host, | ||
| fallback_state, | ||
| None, | ||
| cancelled_reason_from_signal(&signal), | ||
| )?)) | ||
| } | ||
| Err(_) => Ok(CancelCheck::Exit(LoopExit::failed( | ||
| LoopFailureKind::CheckpointRejected, | ||
| exit_id(host, "failed")?, | ||
| ))), | ||
| } | ||
| } |
There was a problem hiding this comment.
The new helper function checkpoint_and_exit_if_cancelled_after_pending_input_ack is almost identical to the existing checkpoint_and_exit_if_cancelled. This duplication is error-prone and has already resulted in a missed update at the start of the loop (line 173 in the full file).
If a loop iteration continues after a drain_followup (line 372), pending_input_ack will be non-empty. However, the cancellation check at line 173 still uses the old non-ack-aware version, meaning a cancellation at that point will exit without acknowledging the drained inputs.
Consider merging these functions by adding the &mut PendingInputAck parameter to the original checkpoint_and_exit_if_cancelled and using it at all cancellation boundaries. Since PendingInputAck::ack is a no-op when empty, this is safe for all call sites, including those where pending_input_ack is not explicitly managed (by passing a default/empty instance).
henrypark133
left a comment
There was a problem hiding this comment.
Review — fix(ws-13): ack drained inputs before cancel exit
Blocker — strict-profile checkpoint failure diverges from checkpoint_and_exit_if_cancelled
checkpoint_and_exit_if_cancelled (line ~1075) was fixed to propagate Err(error) for the strict-profile arm. The new checkpoint_and_exit_if_cancelled_after_pending_input_ack method's equivalent arm returns Ok(CancelCheck::Exit(LoopExit::failed(..., checkpoint_id: None))) instead. Callers see Ok instead of Err, losing the propagation guarantee.
Fix: change the strict arm to match the original:
Err(error) => Err(error),Permissive path — correct. Skipping the ack in the permissive failure arm is right: no durable checkpoint was written, so advancing the cursor would be unsafe.
Call-site coverage — complete. Both drain sites (steering and follow-up) converted to the new method. No remaining gaps.
Test gap. No test exercises the strict-profile checkpoint failure path through the new method. That test would have caught the divergence above.
Blocking on the strict-profile arm fix.
Caveman review findings1🔴 2🟡
|
5bfa687 to
cf11dc4
Compare
0680b63 to
675dc5c
Compare
675dc5c to
05c1b71
Compare
|
Tracking from #3684 review (out of scope for that PR, in scope here):
|
05c1b71 to
fe7c78c
Compare
Code Review —
|
Independent Sonnet Re-Review — fix(ws-13): ack drained inputs before cancel exitReviewed at commit OverviewThis PR closes the input-cursor acknowledgement gap that existed when a cooperative cancellation boundary fired after a drain (steering or follow-up) had populated The logic is sound and the core invariant — "do not advance the input cursor unless a durable checkpoint backs it" — is correctly encoded. No blocking issues found. Correctness🟢 Ack-after-checkpoint ordering is rightIn the happy-cancel path, 🟢 No-ack-on-permissive-checkpoint-failure is justified and commentedThe permissive-profile branch deliberately skips flushing the ack when no durable checkpoint was written. The inline comment explains the reasoning. Consistent with 🟢 Strict-profile error propagation matches original helper
🟡 Five call sites replaced — but scope is narrower than the boundary count impliesOnly the five call sites between steering/follow-up drain and the BeforeModel checkpoint were replaced. Call sites after BeforeModel (lines 278, 285, 317, 334, 345, 352, 372, 402, 419, 430 in the PR head, all using 🟡
|
Context
Stacked follow-up to #3685. This isolates the input cursor acknowledgement safety fix from live cancellation wiring.
What changed
LoopExit::Cancelled.Validation
cargo test -p ironclaw_agent_loop cancellation_afterStack
Base: #3685 /
codex/ws13-live-cancel-wiring