Skip to content

arch(ws-13): add host cancellation accessor - #3648

Merged
henrypark133 merged 9 commits into
reborn-integrationfrom
arch/ws-13
May 15, 2026
Merged

henrypark133 merged 9 commits into
reborn-integrationfrom
arch/ws-13

Conversation

@henrypark133

@henrypark133 henrypark133 commented May 14, 2026 •

Copy link
Copy Markdown
Collaborator

Context

Cancellation observation branch. It gives the planned loop a host-owned cancellation signal without mixing cancellation state into normal chat history or strategy internals.

Master spec: docs/reborn/agent-loop-skeleton.md
Workstream brief: docs/reborn/agent-loop-briefs/host-cancellation-accessor.md
Stack base: arch/ws-8

Latest stack maintenance on 2026-05-14:

  • Rebased this branch onto its current stack base after the WS0 prompt-authority fix and the follow-up WS8/WS14-parent/WS16/WS17 conflict resolutions.
  • Pushed the updated branch with force-with-lease where the remote already existed, or published it as a new branch where it did not.
  • Verified the final ancestry chain from origin/reborn-integration through WS17 before publishing the PR descriptions.

What landed

  • Host cancellation accessor/port shape for planned loop execution.
  • Executor observation of host cancellation at the defined cancellation boundaries.
  • Fail-closed/inert fallback labeling so missing cancellation wiring is explicit.
  • Tests for cancellation access and executor behavior through the real support boundary.
  • Integration fixes for current WS8 test-support and host wiring.

Reviewer focus

  • Cancellation should be observed through host/runtime authority, not synthesized by prompt or model output.
  • A missing cancellation source should be explicit and safe.
  • Cancellation exits still require the same evidence/checkpoint discipline as other loop exits.

Non-goals / deferred work

  • Product inbound cancellation test coverage lands in WS17.
  • Progress event durability is WS12.
  • Default runtime composition is WS16.

Validation

  • cargo test -p ironclaw_loop_support cancellation_port
  • cargo check -p ironclaw_agent_loop --features test-support
  • git diff --check
  • Included in arch/ws-14-parent validation: multi-crate cargo check with ironclaw_agent_loop/test-support.

Stack position

[#3550 ws-0] state/checkpoint foundation -> reborn-integration
   |-- #3551 ws-1 strategy alpha -> ws-0
   |-- #3552 ws-2 strategy beta -> ws-0
   |-- #3553 ws-3 strategy gamma -> ws-0
   |-- #3643 ws-3.5 loop family registry -> ws-0
   '-- #3554 level1-merged -> ws-0
         |-- #3555 ws-4 planner facade -> level1
         |-- #3556 ws-5 default strategies -> level1
         '-- #3557 level2-merged -> level1
               '-- #3596 ws-6a canonical executor -> level2
                     '-- #3597 ws-7 PlannedDriver adapter -> ws-6a
                           '-- #3598 ws-8 integration/test support -> ws-7
                                 |-- #3644 ws-9 capability host wiring -> ws-8
                                 |-- #3645 ws-10 checkpoint load/resume -> ws-8
                                 |-- #3646 ws-11 input port -> ws-8
                                 |-- #3647 ws-12 progress port -> ws-8
                                 |-- #3648 ws-13 cancellation accessor -> ws-8
                                 |-- #3649 ws-15 prompt/identity context -> ws-8
                                 '-- #3650 ws-14-parent integrated host ports -> ws-8
                                       '-- #3651 ws-14 planned default registration -> ws-14-parent
                                             '-- #3652 ws-16 live runtime wiring -> ws-14
                                                   '-- #3653 ws-17 product live cutover -> ws-16

@github-actions github-actions Bot added scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels May 14, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements a cancellation mechanism for the agent loop, introducing a LoopCancellationPort trait and integrating frequent checks within the CanonicalAgentLoopExecutor. It also provides infrastructure for managing cancellation signals and updates relevant host implementations. Feedback suggests refactoring the repetitive cancellation check logic using a macro to improve maintainability. Additionally, it is recommended to utilize the metadata within the LoopCancellationSignal rather than hardcoding exit reasons and to ensure the request method is idempotent to align with the trait's contract.

Comment on lines +123 to +126
state = match self.checkpoint_and_exit_if_cancelled(host, state).await? {
CancelCheck::Continue(state) => state,
CancelCheck::Exit(exit) => return Ok(exit),
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This cancellation check pattern is repeated over 15 times throughout the executor. This significant duplication makes the code harder to read and maintain. Consider introducing a macro or a more flexible helper to encapsulate this logic. Since the return type varies across different functions (e.g., Ok(exit), Ok(ModelStep::Exit(exit)), Ok(BatchStep::Exit(exit))), a macro that accepts an optional exit wrapper could be effective for reducing this boilerplate.

host: &(dyn AgentLoopDriverHost + Send + Sync),
state: LoopExecutionState,
) -> Result<CancelCheck, AgentLoopExecutorError> {
let Some(_signal) = host.observe_cancellation() else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The LoopCancellationSignal returned by host.observe_cancellation() is currently ignored (using _signal). This signal contains valuable metadata such as the reason_kind (e.g., UserRequested, Policy) and the requested_at timestamp. By ignoring it here and hardcoding LoopCancelledReasonKind::HostCancellation in cancelled_exit, the specific reason for cancellation is lost in the loop's exit status and transcript. Consider passing the signal to cancelled_exit to allow for more precise reason mapping.

Comment on lines +24 to +31
pub fn request(&self, reason_kind: LoopCancelReasonKind) {
let signal = LoopCancellationSignal {
reason_kind,
requested_at: Utc::now(),
};
*self.signal.write() = Some(signal);
self.fired.store(true, Ordering::Release);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The request method should be idempotent to satisfy the LoopCancellationPort contract, which states that repeated calls to observe_cancellation must return the same signal. Currently, multiple calls to request will overwrite the signal and update the timestamp. It is recommended to check if the cancellation has already been fired before updating the signal to ensure the first request's metadata is preserved.

    pub fn request(&self, reason_kind: LoopCancelReasonKind) {
        if self.fired.load(Ordering::Acquire) {
            return;
        }
        let mut signal_lock = self.signal.write();
        if signal_lock.is_some() {
            return;
        }
        let signal = LoopCancellationSignal {
            reason_kind,
            requested_at: Utc::now(),
        };
        *signal_lock = Some(signal);
        self.fired.store(true, Ordering::Release);
    }

@serrrfirat

Copy link
Copy Markdown
Collaborator

Summary

Reviewed WS13 PR #3648 only.
Base cbdd70890afb200cdfe47076b23a885f0c70cd8c → head 898fa47c178c8093d8df52b7b24ae15379695b4f.

Cancellation probes can discard already-completed model/capability work, and cancellation signal handle does not latch first signal.

Validation:

  • cargo check -p ironclaw_reborn ✅

Findings

# Sev Category File:Line Issue Fix suggestion
1 High Correctness / Async ordering crates/ironclaw_agent_loop/src/executor.rs:210-220 Cancellation is checked after stream_model() returns but before finalize_assistant_message(). If cancel fires while model call is in flight, executor exits cancelled and drops completed reply instead of persisting already-returned model output. Move cancellation checks to before starting externally visible work, or after applying returned work to state. Add test: cancel after model response before finalize still preserves reply or follows explicit policy.
2 High Correctness / Async ordering crates/ironclaw_agent_loop/src/executor.rs:484-512 Cancellation is checked after invoke_capability_batch() returns and between draining returned outcomes. If cancel fires during batch execution, executor can exit before recording some/all completed outcomes. Do not cancel between completed side effect and state application. Drain/apply returned outcomes first, then observe cancellation before next external work. Add batch-return/mid-drain cancel tests.
3 Medium Concurrency / Contract crates/ironclaw_loop_support/src/cancellation_port.rs:23-30, crates/ironclaw_turns/src/run_profile/host.rs:1407-1410 RunCancellationHandle::request() overwrites stored signal on every call, but LoopCancellationPort contract says reads must be idempotent and return the same signal after first fire. Repeated cancel requests can mutate reason_kind/requested_at after observers saw first signal. Latch first signal only (get_or_insert/CAS-style), and add repeated-request test proving first signal is preserved.

Security/data-flow notes

  • Scope leak not found in cancellation accessor; issue is async ordering and idempotent evidence.

Missing tests

  • Cancel after model response but before reply finalize.
  • Cancel after capability batch returns but before/while outcomes drain.
  • Duplicate request() preserves first signal.

@zmanian zmanian mentioned this pull request May 14, 2026
2 tasks done
@zmanian

zmanian commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Review notes — WS13 cancellation accessor

Pull-based cooperative cancellation with AtomicBool + snapshot is the right primitive for boundary checking — cheap, non-blocking, idempotent. RunCancellationObservationKind::{LiveCapable, InertFallback} makes the inert-default observable rather than silently no-op. Strict vs permissive require_final_checkpoint is honored correctly (cancellation under strict maps to Failed/CheckpointRejected, not silent cancel). Two items to resolve.

Cooperative-only — document the limitation

build_prompt_bundle, stream_model, and invoke_capability are awaited to completion before the next observation. A stuck model stream or capability call will ignore cancellation indefinitely. The brief scopes this to "boundary observation," but the limitation needs to be discoverable from the code, not just the brief — a doc comment on LoopCancellationPort (or on checkpoint_and_exit_if_cancelled) stating "cancellation is not preempted across in-flight host calls" would prevent future readers from assuming preemption.

WS16 wiring will need to compose this with a stream-level abort if true preemption is wanted.

Reconcile with WS6a's Cancelled variant

WS6a (#3596) declared AgentLoopExecutorError::Cancelled but never constructs it; host Cancelled errors get mapped to ModelErrorClass::Transient. WS13 is the natural place to construct that variant — your boundary check has the signal. Either:

  1. Have checkpoint_and_exit_if_cancelled return AgentLoopExecutorError::Cancelled on cancellation (rather than the current bare LoopExit::Cancelled path), so the executor's error taxonomy is consistent, or
  2. Coordinate with arch(ws-6a): add canonical loop executor #3596 to remove the dead variant.

Either way, WS6a + WS13 + WS14-parent should converge on cancellation semantics before WS16 wires more.

Minor

  • interrupted_message_refs: Vec::new() is always empty — // TODO(WS-17) marker would clarify future intent.
  • with_cancellation_factory takes Arc<F> then re-coerces to Arc<dyn> — simplify the signature.
  • chrono added to two crates just for DateTime<Utc> in the signal — SystemTime would have avoided the dep churn.

Test coverage (pre-iteration, mid-loop, permissive checkpoint failure, strict checkpoint failure, port-level concurrent visibility) is solid.

@henrypark133

Copy link
Copy Markdown
Collaborator Author

Follow-up from the ws-6a review pass on #3596:

#3596 now handles cancellation that is already returned through model/capability host calls: capability cancelled failures checkpoint and return a terminal LoopExit::Cancelled, while host-level cancelled errors remain unrecoverable when no trustworthy loop exit can be produced.

This WS13 cancellation-accessor PR still owns cancellation observation between strategy/host calls. Please keep tests for the between-call boundaries (planned_driver_cancels_between_strategy_calls, planned_driver_cancels_mid_batch_recovery_loop or equivalent) and document the below-loop boundary: aborting an already in-flight capability invocation requires cooperation from the capability host/runtime, not just the executor polling a cancellation accessor.

@henrypark133
henrypark133 force-pushed the arch/ws-8 branch 2 times, most recently from 109d2a3 to 0615a4e Compare May 15, 2026 04:28
Base automatically changed from arch/ws-8 to reborn-integration May 15, 2026 04:34
@henrypark133
henrypark133 force-pushed the arch/ws-13 branch 3 times, most recently from 1d592bb to 22f8590 Compare May 15, 2026 05:06
@henrypark133
henrypark133 marked this pull request as ready for review May 15, 2026 06:18
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f08aad958a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +954 to +957
Err(_) => Ok(CancelCheck::Exit(LoopExit::failed(
LoopFailureKind::CheckpointRejected,
exit_id(host, "failed")?,
))),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return executor error instead of invalid Failed exit here

When cancellation is observed under a profile with require_final_checkpoint = true, this fallback emits LoopExit::failed(...) without a checkpoint ID. validate_failed_exit in crates/ironclaw_turns/src/loop_exit.rs still requires a verified final checkpoint for strict profiles, so this exit is guaranteed to be flagged as MissingFinalCheckpoint and remapped as a protocol violation rather than a trusted CheckpointRejected failure. In practice, the strict-path fallback here cannot produce the “well-formed” terminal result it intends; it should surface an executor/host error (or another path that preserves strict checkpoint guarantees) instead.

Useful? React with 👍 / 👎.

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review findings

  1. crates/ironclaw_agent_loop/src/executor.rs:954

Strict-profile cancellation fallback returns LoopExit::failed(..., checkpoint_id: None) when final checkpoint write fails. validate_failed_exit still requires a verified final checkpoint for strict profiles, so this becomes MissingFinalCheckpoint / protocol violation instead of the intended trusted CheckpointRejected failure. This is already covered by the current inline Codex comment on the PR, and I agree with it. Return an executor/host error or another path that preserves strict checkpoint guarantees.

  1. crates/ironclaw_agent_loop/src/executor.rs:194-200 and crates/ironclaw_agent_loop/src/executor.rs:321-328

Cancellation can now exit while pending_input_ack is queued. After drain_user_inputs or drain_followup, the state cursor is advanced and an ack cursor is stored, but the new cancellation probe can write the final checkpoint and return before pending_input_ack.ack(host) runs. That leaves consumed steering/follow-up input unacked even though the terminal checkpoint made the cursor durable, so the input can be redelivered or remain stuck depending on the host queue. Fix by threading pending acks into the cancellation exit path, or avoid cancellation exits between input drain and the ack that follows the durable checkpoint. Add a regression where cancellation fires during/after poll_inputs.

Reviewed read-only against f08aad958a. I did not run tests.

Copy link
Copy Markdown
Collaborator

Split the follow-up fixes out of this PR into a small stacked set so this PR can stay focused on WS13:

  1. fix(ws-13): verify cancellation from turn state #3684 — durable cancellation evidence fix

    • Base: arch/ws-13
    • Scope: addresses the original review finding by making cancellation evidence read durable turn state.
  2. fix(ws-13): wire live cancellation into host #3685 — live cancellation wiring

    • Base: codex/ws13-cancel-evidence
    • Scope: wires the turn-state-backed cancellation factory through the Reborn text host and coordinator wake path.
  3. fix(ws-13): ack drained inputs before cancel exit #3686 — cancel-exit ack safety

    • Base: codex/ws13-live-cancel-wiring
    • Scope: keeps input ack behavior correct when cancellation is observed after draining steering/follow-up input.

The current PR should remain the stack anchor; the additional behavior is now isolated in the follow-up PRs.

@henrypark133
henrypark133 force-pushed the arch/ws-13 branch 2 times, most recently from 684d837 to e17c627 Compare May 15, 2026 16:06
henrypark133 and others added 4 commits May 15, 2026 11:18
…riant ownership, resolve review comments

- Remove redundant Arc<dyn> intermediate binding in with_cancellation_factory
- Doc comment on AgentLoopExecutorError::Cancelled: constructed for in-flight
  model/capability cancelled returns, distinct from between-call boundary
- Add cooperative-boundary design comment above cancellation check sites
- Document intentional HostCancellation coarsening in cancelled_reason_from_signal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
henrypark133 and others added 4 commits May 15, 2026 11:18
… fix strict-profile checkpoint failure path

- LoopCancellationPort: expand doc comment to explicitly state cancellation is cooperative boundary-observation only, not preempted across in-flight host calls
- executor: propagate CheckpointFailed error on strict-profile final checkpoint failure during cancellation (was returning LoopExit::Failed with no checkpoint_id, which fails validate_failed_exit's MissingFinalCheckpoint check)
- Update cancelled_exit TODO marker to ws-17 standard format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ter rebase

Rebase onto WS-11 introduced a duplicate `reason_kind` parameter in
`cancelled_exit_with_reason` and left three call sites passing a 4th argument
to the 3-arg `cancelled_exit`. Drop the duplicate param and route the
signal-driven cancel checks through `cancelled_exit_with_reason` directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eHost and ResumePayloadHost

Adding LoopCancellationPort to AgentLoopDriverHost supertrait broke
two test-only host structs that were not updated. Both return None
(no active cancellation signal), consistent with their roles as
controlled test doubles that do not model host-initiated cancellation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@henrypark133
henrypark133 merged commit 1c88442 into reborn-integration May 15, 2026
15 checks passed
@henrypark133
henrypark133 deleted the arch/ws-13 branch May 15, 2026 18:45
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
* arch: ws-13-host-cancellation-accessor (iter 0, approved)

* fix(ws-13): mark inert cancellation fallback

* fix(ws-13): address cancellation review feedback

* fix(ws-13): simplify with_cancellation_factory, document Cancelled variant ownership, resolve review comments

- Remove redundant Arc<dyn> intermediate binding in with_cancellation_factory
- Doc comment on AgentLoopExecutorError::Cancelled: constructed for in-flight
  model/capability cancelled returns, distinct from between-call boundary
- Add cooperative-boundary design comment above cancellation check sites
- Document intentional HostCancellation coarsening in cancelled_reason_from_signal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ws-13): resolve review comments — doc cancellation cooperativity, fix strict-profile checkpoint failure path

- LoopCancellationPort: expand doc comment to explicitly state cancellation is cooperative boundary-observation only, not preempted across in-flight host calls
- executor: propagate CheckpointFailed error on strict-profile final checkpoint failure during cancellation (was returning LoopExit::Failed with no checkpoint_id, which fails validate_failed_exit's MissingFinalCheckpoint check)
- Update cancelled_exit TODO marker to ws-17 standard format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ws-13): fix duplicate parameter and 4-arg cancelled_exit calls after rebase

Rebase onto WS-11 introduced a duplicate `reason_kind` parameter in
`cancelled_exit_with_reason` and left three call sites passing a 4th argument
to the 3-arg `cancelled_exit`. Drop the duplicate param and route the
signal-driven cancel checks through `cancelled_exit_with_reason` directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ws-13): cargo fmt after rebase conflict resolution

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ws-13): cargo fmt after second rebase onto reborn-integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ws-13): add missing LoopCancellationPort impls for ForbiddenResumeHost and ResumePayloadHost

Adding LoopCancellationPort to AgentLoopDriverHost supertrait broke
two test-only host structs that were not updated. Both return None
(no active cancellation signal), consistent with their roles as
controlled test doubles that do not model host-initiated cancellation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: dependencies Dependency updates size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants