Skip to content

Drain DeferredBusy messages when the blocking run reaches terminal state - #4812

Closed
henrypark133 wants to merge 21 commits into
mainfrom
feat/deferred-busy-drain
Closed

henrypark133 wants to merge 21 commits into
mainfrom
feat/deferred-busy-drain

Conversation

@henrypark133

@henrypark133 henrypark133 commented Jun 12, 2026 •

Copy link
Copy Markdown
Collaborator

Completes the blocked-thread UX arc (#4799 routing, #4811 feedback): messages sent while a run is blocked on a gate are no longer swallowed forever — they drain automatically once the blocking run terminates.

Problem

A run blocked on an approval/auth gate holds the thread's active lock (keeps_active_lock). New inbound messages persist as MessageStatus::DeferredBusy with no run created — and nothing ever resubmits them. Even after the user approves the gate and the run completes, every message sent during the block stays silently dead.

Change

  • ironclaw_threads: SessionThreadService::list_deferred_busy_messages — sequence-ordered (oldest first) DeferredBusy user messages for a thread scope; implemented on both backends (in-memory + filesystem) with contract tests on each.
  • ironclaw_reborn_composition: DeferredBusyDrainObserver, subscribed to the turn lifecycle bus via additional_required_observers (same pattern as SubagentCompletionObserver, incl. bind_coordinator back-reference). On every terminal event (is_terminal() — the exact inverse of keeps_active_lock, so Completed/Failed/Cancelled/RecoveryRequired all drain) it resubmits the oldest deferred message; one-at-a-time cascade — the resubmitted run's own terminal event drains the next.

Safety properties:

  • Identity preserved: resubmission carries the message record's original actor_id; a record without one is left deferred rather than misattributed to the thread owner.
  • Idempotent: coordinator idempotency key drain:<message_id> — a duplicate terminal event replays the same run instead of double-submitting; mark_message_submitted flips the record so the product replay path returns AlreadySubmitted.
  • Never poisons the terminal path: every failure (list error, invalid refs, busy thread, submit error) logs and leaves the message deferred; next terminal event retries.

Tests

Thread-service contract tests (both impls: ordering, filtering, empty), drain integration (blocked run cancelled → deferred message becomes a submitted run), idempotency (double terminal event → single submission), e2e harness test.

Reviewer note — submission path

The drain submits through TurnCoordinator directly with refs reconstructed from the thread record, rather than the product-workflow inbound replay path (which requires the original product envelope that isn't persisted). Same precedent as trigger_poller_trusted_submit. The product-side replay still converges via the message-status guard (AlreadySubmitted). If we'd rather persist enough of the envelope to route drains through InboundTurnService replay, that's a structural follow-up worth discussing on this PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Deferred-message drain observer to resubmit deferred-busy messages; session thread services expose listing of deferred-busy messages.
    • Runtime composition can accept additional required observers for wiring the drain observer.
  • Bug Fixes

    • Timeline responses now scrub internal routing binding references before being returned to clients.
  • Chores / Tests

    • Persisted binding-ref/idempotency fields for deferred messages; extensive contract and integration tests covering defer/retry/drain behaviors and timeline scrubbing.

henrypark133 and others added 4 commits June 11, 2026 23:37
Adds `ListDeferredBusyMessagesRequest` and `list_deferred_busy_messages`
to the `SessionThreadService` trait contract. Both `InMemorySessionThreadService`
and `FilesystemSessionThreadService` implement the query: returns only
`DeferredBusy` user messages for the given thread scope, ordered ascending
by sequence (oldest first). Non-matching scope returns empty, not error.

Includes 4+4 contract tests (in-memory and filesystem) covering the empty,
filter-only-deferred, ordering, and wrong-scope cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds `DeferredBusyDrainObserver` in `ironclaw_reborn_composition` that
observes terminal turn events and resubmits the oldest `DeferredBusy`
message for the affected thread.  One-at-a-time cascade semantics: each
resubmission kicks off the next drain when that run terminates.

Implementation:
- `DefaultPlannedRuntimeParts.additional_required_observers` field wires
  extra `TurnCommittedEventObserver` instances into the lifecycle bus
  alongside `SubagentCompletionObserver`
- `DeferredBusyDrainObserver` follows the `new_unbound` + `bind_coordinator`
  pattern from `SubagentCompletionObserver`; coordinator bound after build
- Drain failures are non-fatal: `warn!` + `Ok(())` so the terminal event
  path is never poisoned
- Idempotency key `drain:<message_id>` prevents double-submission on
  duplicate drain fires

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Inline #[cfg(test)] mod inside deferred_busy_drain covers:
- Scenario A: DeferredBusy message is resubmitted after blocking run
  is cancelled (happy-path cascade)
- Scenario B: second terminal event does not double-submit an already-
  Submitted message (idempotency key guard)

Both tests use in-memory collaborators (InMemorySessionThreadService,
InMemoryTurnStateStore, DefaultTurnLifecycleEventBus,
DefaultTurnCoordinator) wired the same way as production composition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A deferred message must resubmit as its original sender. The previous
owner-fallback could misattribute a message from one user to the thread
owner when actor_id was missing; leave such records deferred instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the size: XL 500+ changed lines label Jun 12, 2026
@coderabbitai

coderabbitai Bot commented Jun 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds persisted canonical turn binding refs and a ListDeferredBusyMessagesRequest; extends SessionThreadService (defer signature + listing); implements backend listing and marker fast-paths; introduces DeferredBusyDrainObserver wired into runtime; updates defer/retry to persist refs and scrubs timeline output; broad test coverage.

Changes

Deferred-Busy Drain Flow

Layer / File(s) Summary
Thread contracts and session API
crates/ironclaw_threads/src/contract.rs, crates/ironclaw_threads/src/service.rs, crates/ironclaw_threads/src/lib.rs, crates/ironclaw_reborn/src/thread_scope.rs
ThreadMessageRecord gains turn_source_binding_ref and turn_reply_target_binding_ref; ListDeferredBusyMessagesRequest added; SessionThreadService extends mark_message_deferred_busy and adds list_deferred_busy_messages; Arc<> delegation updated; terminal-scope helpers exposed.
Filesystem & InMemory deferred behavior
crates/ironclaw_threads/src/filesystem_service.rs, crates/ironclaw_threads/src/in_memory.rs
Backends persist canonical refs and idempotency keys on defer, maintain per-thread presence marker (file or HashSet), implement list_deferred_busy_messages with ascending-sequence ordering, after_sequence (strict >) cursor, limit, and marker tombstone rules; history projection includes new fields.
DeferredBusyDrainObserver core & tests
crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs, .../tests.rs
Adds observer that pages deferred messages, validates actor and canonical refs, builds replay SubmitTurnRequest (idempotency fallback), submits via coordinator, marks submitted on Accepted, stops on ThreadBusy, and treats failures as non-fatal. Extensive scenario tests A–L.
Runtime registration & binding
crates/ironclaw_reborn/src/runtime.rs, crates/ironclaw_reborn_composition/src/runtime.rs, crates/ironclaw_reborn_composition/src/lib.rs, tests/harness files
DefaultPlannedRuntimeParts gains additional_required_observers; runtime build subscribes them; composition registers DeferredBusyDrainObserver as required observer and binds coordinator after composition; harness/tests set field to empty vec where used.
Workflow defer/retry and facade scrubbing
crates/ironclaw_product_workflow/src/inbound_turn.rs, .../reborn_services.rs, crates/ironclaw_product_workflow/src/lib.rs, crates/ironclaw_webui_v2/src/handlers.rs
Submit paths capture canonical source/reply binding refs, persist them on defer via updated mark_message_deferred_busy call, retry with cloned request preserving idempotency, and get_timeline scrubs internal routing fields (including idempotency key) at facade boundary; facade re-exports ThreadMessageRecord.
Thread service deferred-list contract tests
crates/ironclaw_threads/tests/*, filesystem & in-memory
Comprehensive contract tests added for empty results, deferred-only filtering, oldest-first ordering, wrong-scope empty, limit behaviors (including 0), strict after_sequence cursor semantics, marker fast-path, immediate marker visibility, tombstone idempotency, and idempotency-key persistence.
Adapters, doubles, fixtures, and webui tests
crates/ironclaw_loop_support/*, crates/ironclaw_product_workflow/tests/*, crates/ironclaw_reborn_composition/*, crates/ironclaw_webui_v2/tests/*
Test doubles updated to accept new defer params and add/implement list_deferred_busy_messages (delegate, stub, or panic where appropriate); fixtures updated to include new fields; webui contract verifies scrubbed wire response.

Repository Hygiene

Layer / File(s) Summary
Ignore local codegraph artifact directory
.gitignore
Adds .codegraph/ to ignore rules.

Sequence Diagram (high-level drain flow):

sequenceDiagram
  participant TurnLifecycleBus
  participant DeferredBusyDrainObserver
  participant SessionThreadService
  participant TurnCoordinator

  TurnLifecycleBus->>DeferredBusyDrainObserver: observe_committed_state/event(terminal)
  DeferredBusyDrainObserver->>SessionThreadService: list_deferred_busy_messages(request)
  SessionThreadService-->>DeferredBusyDrainObserver: deferred messages[]
  DeferredBusyDrainObserver->>TurnCoordinator: submit_turn(replayed deferred message)
  TurnCoordinator-->>DeferredBusyDrainObserver: Accepted | ThreadBusy | Error
  DeferredBusyDrainObserver->>SessionThreadService: mark_message_submitted(if accepted)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested reviewers

  • serrrfirat
  • abbyshekit

No CLAUDE.md/AGENTS.md/.claude/rules invariant violations detected.

"Deferred messages wait, then quietly fly,
Terminal runs whisper: give the next one a try,
Canonical refs travel, then are scrubbed from view,
Tests press the corners; the drain wakes anew."

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive Title uses descriptive action verb and domain language ('Drain DeferredBusy') but lacks conventional commit prefix (type/scope). Adopt conventional commits format: 'feat(ironclaw_threads): drain DeferredBusy messages when blocking run reaches terminal state' or similar type(scope): pattern per repository guidelines.
✅ Passed checks (3 passed)
Check name Status Explanation
Description check ✅ Passed Description covers problem, solution, safety properties, and tests comprehensively, but omits explicit validation checklist items (fmt/clippy/build/test passes) required by template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jun 12, 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 introduces a mechanism to drain and resubmit user messages that were parked with a DeferredBusy status once a blocking run terminates. It adds the list_deferred_busy_messages query to the thread service and implements the DeferredBusyDrainObserver to handle the resubmission. However, the current implementation of the drain observer is vulnerable to head-of-line blocking and queue stalling if the oldest deferred message fails validation. To prevent the queue from permanently stalling, the observer should loop through deferred messages, discard or mark invalid ones as failed, and proceed to the next message instead of returning early.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +107 to +110
let oldest = match deferred.into_iter().next() {
Some(m) => m,
None => return Ok(()),
};

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.

high

Head-of-Line Blocking & Queue Stalling Vulnerability

Currently, if the oldest deferred message fails any validation (e.g., resolve_actor_user_id fails, or bindings are invalid), the observer logs a warning and returns Ok(()), leaving the message in the DeferredBusy state.

This causes two severe issues:

  1. Head-of-Line Blocking: Since the invalid message remains the oldest message in the database, every subsequent terminal event will pick it up, fail validation, and return early. No other deferred messages on this thread will ever be drained.
  2. Queue Stalling: Because no new run is started when validation fails, no future terminal events will fire to trigger another drain, leaving all subsequent valid messages permanently stuck.

Recommended Solution

Instead of only processing the first message and returning early on validation failure, we should loop through the deferred messages:

  • If a message fails validation, mark it as submitted with a sentinel error ID (e.g., "failed_validation") to remove it from the DeferredBusy state, and continue the loop to try the next message.
  • If a message is successfully submitted, mark it as submitted and break the loop (as the new run will eventually trigger the next drain).
  • If submission fails with ThreadBusy, leave it deferred and break the loop.
References
  1. In batch processing or polling workers, handle individual record processing errors (such as due-record processing) per record by reporting or logging the failure and continuing to subsequent records, rather than aborting the entire loop.

@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: 0f7975956c

ℹ️ 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 +304 to +307
/// State-based observation is not needed; drain is driven by committed
/// events only.
fn observes_state(&self, _state: &TurnRunState) -> bool {
false

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 Observe terminal state publications too

When a blocked run is resumed and then finishes normally, the terminal transition is published through TurnLifecycleEventBus::publish_state (see crates/ironclaw_turns/src/lifecycle.rs for complete_run/fail_run/runner cancellation), which only calls observers whose observes_state returns true. Because this observer returns false here and observe_committed_state is a no-op, DeferredBusy messages still never drain for the main approval/auth success path; the new drain only runs for coordinator-origin terminal events such as direct cancel_run. Please handle terminal TurnRunStates as well, or otherwise ensure runner-origin terminal events reach the drain.

Useful? React with 👍 / 👎.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain DeferredBusy messages after a gate-blocking run reaches terminal state, preserving identity and idempotency.

Stats: 2 findings posted (from 11 raw reviewer findings; stale/off-diff findings excluded; overlapping scan findings deduped) across 2 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0.

Bugs

  1. High Drain rejects valid persisted binding IDs over the turn-ref limit (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:130-156, confidence 80) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:130
    Deferred messages persist raw product binding IDs, while normal product submission bounds/hashes those raw values before constructing turn refs. The drain uses raw constructors directly, so valid deferred messages with long binding IDs can remain stuck forever.

Performance / Concurrency

  1. Medium Drain lists the whole thread history to use one message (crates/ironclaw_threads/src/filesystem_service.rs:904-910, confidence 82) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:904
    The observer drains one message at a time, but the filesystem backend materializes and sorts every deferred user message on each terminal event, making backlog recovery repeat full-history scans.

.as_deref()
.filter(|s| !s.is_empty())
{
Some(raw) => match SourceBindingRef::new(raw) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High — Drain rejects valid persisted binding IDs over the turn-ref limit.

Deferred messages persist the raw product source_binding_id / reply_target_binding_id strings. The normal product submission path converts those raw values through bounded_source_binding_ref / bounded_reply_target_binding_ref, hashing oversized values before constructing turn refs, but this drain path calls SourceBindingRef::new(raw) and ReplyTargetBindingRef::new(raw) directly. Since those constructors reject values over 256 bytes while product binding material can be much longer, a valid DeferredBusy message can be logged as invalid and left deferred forever instead of draining after the blocking run terminates.

Fix: Reconstruct the coordinator refs with the same bounded binding-ref conversion used by the product submit/replay path, or persist and replay the already-bounded coordinator refs.

if !thread_exists {
return Ok(Vec::new());
}
let mut messages: Vec<ThreadMessageRecord> = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Drain lists the whole thread history to use one message.

The observer only submits the oldest deferred message, but this backend loads every message in the thread, filters all DeferredBusy user messages, and sorts the whole filtered list. Because draining is one-at-a-time, a large busy backlog repeats a full history scan for each drained turn, turning recovery into O(total_history * deferred_backlog) work on the terminal lifecycle path.

Fix: Add a bounded service method for the oldest DeferredBusy user message, or make the filesystem implementation walk the existing sequence order and stop once it finds the first eligible record instead of collecting all matches.

Also flagged by: security/Medium

@henrypark133

Copy link
Copy Markdown
Collaborator Author

Follow-ups from the submission-path reviewer note (trusted-resubmit seam, stale-intent policy, startup drain sweep) are tracked in #4817 — decided not to address in this PR.

henrypark133 and others added 3 commits June 12, 2026 08:18
…tory scan

Without a bound every drain attempt loads the full thread history.
Add `limit: Option<usize>` to `ListDeferredBusyMessagesRequest`,
applied after sequence-ascending sort in both the in-memory and
filesystem implementations. Drain callers pass `Some(8)` — enough
for the skip-invalid loop (Fix 3) to make progress.

Contract tests extended to cover limit=2 (oldest-first subset)
and limit=0 (empty) on both implementations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…drain reuse

The deferred-busy drain resubmits messages using the same binding refs
that the original product submission produced. The helpers that apply
the bounded UUID-hash fallback for oversized raw ids were `pub(crate)`,
making them inaccessible from `ironclaw_reborn_composition`.

Expose `bounded_source_binding_ref`, `bounded_reply_target_binding_ref`,
and `DEFAULT_BINDING_REF_RAW_MAX_BYTES` as `pub` and re-export from
the crate's public surface. This is the canonical approach rather than
duplicating the hashing logic in the drain, which would let the two paths
drift and break AlreadySubmitted replay convergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(publish_state path)

Three bugs fixed together as they all change the same function:

1. CRITICAL — drain never fired on normal approval completion.
   `DeferredBusyDrainObserver::observes_state` returned `false` and
   `observe_committed_state` was a no-op, so runner-origin transitions
   (complete_run/fail_run/runner cancel) never triggered the drain.
   The production approval flow ends with the runner calling complete_run
   which goes through publish_state → observe_committed_state, not the
   coordinator cancel_run path that the existing test exercised.
   Fix: implement `observes_state(state) -> state.status.is_terminal()`
   and `observe_committed_state` to call a shared `drain_for_scope` helper.
   The idempotency key (drain:<message_id>) makes a double-drain from both
   state and event publication produce AlreadySubmitted on the second call.
   New test: `deferred_message_submitted_after_blocking_run_completes_via_publish_state`

2. CRITICAL — oversized binding ids bricked the drain.
   `SourceBindingRef::new(raw)` / `ReplyTargetBindingRef::new(raw)` reject
   values >256 bytes, silently leaving the message parked forever.
   Fix: use `bounded_source_binding_ref("src", raw, 240)` and
   `bounded_reply_target_binding_ref("reply", raw, 240)` — the same
   bounded UUID-hash fallback the original inbound turn path used when
   persisting the binding ids, ensuring ref convergence on resubmission.
   New test: `drain_handles_oversized_binding_id_via_uuid_fallback`

3. High — head-of-line blocking: invalid first message stalled the queue.
   A single corrupt entry caused `return Ok(())` so no subsequent messages
   could be drained, and since no run started, no future terminal event
   would fire — permanent stall for all later messages.
   Fix: iterate in sequence order; on validation failure log warn! and
   `continue` to the next entry; on successful submit `break`; on
   ThreadBusy `break` (leave all deferred). Failed messages are not mutated
   (LLM-data retention). `ListDeferredBusyMessagesRequest.limit = Some(8)`
   caps the history scan while giving enough room for the skip loop.
   New test: `drain_skips_invalid_message_and_submits_next`

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@henrypark133

Copy link
Copy Markdown
Collaborator Author

Review comments addressed in 242cf33..37dbd35:

  • Drain never fired on the normal success path (codex P1 — confirmed, critical): the observer only watched committed events, but runner-origin terminal transitions (resume → complete/fail) publish through publish_state. The observer now implements observes_state for terminal statuses and drains from observe_committed_state too; the drain:<message_id> idempotency key makes a duplicate event+state pair a replay, not a double-submit. Regression test drives the real path: claim → complete_run via LifecyclePublishingTurnStateStore → deferred message submitted (deferred_message_submitted_after_blocking_run_completes_via_publish_state).
  • Oversized binding ids bricked the drain (henrypark133 — confirmed, High): the drain now reuses the product path's bounded_source_binding_ref / bounded_reply_target_binding_ref (exposed as pub, not duplicated), so refs converge with what the original submission produced. Test: 300-byte binding id round-trips defer → drain.
  • Head-of-line blocking / queue stall (gemini — confirmed, High, modified fix): the drain iterates oldest-first and skips validation-failed messages instead of stopping. Deliberately NOT taking the suggested sentinel-status mutation — falsifying a message's status conflicts with the data-retention rule; failed records stay DeferredBusy, are re-examined and re-skipped each drain. Bounded window (limit 8) caps the rescan cost. Test: invalid oldest + valid second → second drains.
  • Full history scan per drain (henrypark133): ListDeferredBusyMessagesRequest gains limit, applied after sequence sort in both backends; drain reads at most 8. Contract tests on both impls.

clippy zero warnings; 877 tests pass (pre-existing local_dev_runtime_* parallel flake unchanged, also fails on main).

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs (1)

451-456: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't drop mission_id when rebuilding ThreadScope.

Both helpers hard-code mission_id: None, so deferred messages parked on mission-scoped threads won't be found or marked submitted during drain. The filesystem backend keys thread storage by mission_id, and the thread services compare full scope, so this silently turns the new drain into a no-op for that class of threads. Please carry mission scope through the lifecycle surface, or recover the canonical ThreadScope before calling the thread service.

As per coding guidelines, "Preserve tenant/user/agent/project/mission/thread scope on authority, state, memory, process, network, outbound, resource, and event records."

Also applies to: 471-476

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs` around lines
451 - 456, The ThreadScope reconstruction is dropping mission context (setting
mission_id: None) causing mission-scoped deferred messages to be missed; update
the ThreadScope creation in the code paths around ThreadScope { tenant_id:
event.scope.tenant_id.clone(), agent_id, project_id:
event.scope.project_id.clone(), owner_user_id, mission_id: None, ... } to carry
through the original mission id (e.g. use event.scope.mission_id.clone() or
recover the canonical ThreadScope before calling the thread service) so the
filesystem backend and thread service comparisons see the full scope; apply the
same fix to the other occurrence noted (around the 471-476 region).

Source: Coding guidelines

🧹 Nitpick comments (1)
crates/ironclaw_threads/tests/session_thread_contract.rs (1)

2729-2745: ⚡ Quick win

Assert the exact oldest deferred rows here.

This only proves "two increasing sequences," not "the oldest two after sorting." A truncate-before-sort regression could still pass. Please assert the returned sequences/content are exactly alpha then beta.

Suggested assertion tighten-up
-    assert_eq!(result.len(), 2, "limit=2 must cap at 2");
-    // Must be oldest first.
-    let seqs: Vec<_> = result.iter().map(|m| m.sequence).collect();
-    assert!(
-        seqs.windows(2).all(|w| w[0] < w[1]),
-        "limited results not in ascending sequence order: {seqs:?}"
-    );
+    assert_eq!(
+        result.iter().map(|m| m.sequence).collect::<Vec<_>>(),
+        vec![1, 2],
+        "limit=2 must keep the oldest deferred rows",
+    );
+    assert_eq!(
+        result.iter().map(|m| m.content.as_deref()).collect::<Vec<_>>(),
+        vec![Some("alpha"), Some("beta")],
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_threads/tests/session_thread_contract.rs` around lines 2729 -
2745, The test currently only checks two ascending sequences from the
list_deferred_busy_messages call; instead assert that the returned rows exactly
match the expected oldest two (alpha then beta). After calling
service.list_deferred_busy_messages(ListDeferredBusyMessagesRequest { ... }) and
unwrapping into result, replace the loose seqs check with an exact equality
check that result[0] corresponds to alpha and result[1] corresponds to beta
(compare their sequence field or full message identity used elsewhere in the
test), e.g. assert that result.len()==2 and that result[0].sequence ==
alpha.sequence and result[1].sequence == beta.sequence (or compare message
IDs/content) so the test fails on any truncate-before-sort regression.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs`:
- Around line 451-456: The ThreadScope reconstruction is dropping mission
context (setting mission_id: None) causing mission-scoped deferred messages to
be missed; update the ThreadScope creation in the code paths around ThreadScope
{ tenant_id: event.scope.tenant_id.clone(), agent_id, project_id:
event.scope.project_id.clone(), owner_user_id, mission_id: None, ... } to carry
through the original mission id (e.g. use event.scope.mission_id.clone() or
recover the canonical ThreadScope before calling the thread service) so the
filesystem backend and thread service comparisons see the full scope; apply the
same fix to the other occurrence noted (around the 471-476 region).

---

Nitpick comments:
In `@crates/ironclaw_threads/tests/session_thread_contract.rs`:
- Around line 2729-2745: The test currently only checks two ascending sequences
from the list_deferred_busy_messages call; instead assert that the returned rows
exactly match the expected oldest two (alpha then beta). After calling
service.list_deferred_busy_messages(ListDeferredBusyMessagesRequest { ... }) and
unwrapping into result, replace the loose seqs check with an exact equality
check that result[0] corresponds to alpha and result[1] corresponds to beta
(compare their sequence field or full message identity used elsewhere in the
test), e.g. assert that result.len()==2 and that result[0].sequence ==
alpha.sequence and result[1].sequence == beta.sequence (or compare message
IDs/content) so the test fails on any truncate-before-sort regression.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6cb5e327-cc9d-498c-910b-d798bd4c4763

📥 Commits

Reviewing files that changed from the base of the PR and between 0f79759 and 37dbd35.

📒 Files selected for processing (8)
  • crates/ironclaw_product_workflow/src/binding_ref.rs
  • crates/ironclaw_product_workflow/src/lib.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs
  • crates/ironclaw_threads/src/contract.rs
  • crates/ironclaw_threads/src/filesystem_service.rs
  • crates/ironclaw_threads/src/in_memory.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs
  • crates/ironclaw_threads/tests/session_thread_contract.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/ironclaw_product_workflow/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/ironclaw_threads/src/contract.rs
  • crates/ironclaw_threads/src/in_memory.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs

The root-level RebornBinaryE2EHarness constructs DefaultPlannedRuntimeParts
literally and was missed when the field was added; only workspace-crate
fixtures were updated. Fixes the root-tests / legacy-tests CI compile break.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain DeferredBusy messages after a blocking gate run reaches terminal state.
Stats: 9 findings (from 13 raw, 9 after filtering/dedup) across 3 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 2. Refreshed against head 72ad2412abe41ef9f015b29bc35b67afc61f015e.

bugs

  1. High Drain rewrites non-product binding refs with product prefixes (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:150-185, confidence 85) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:150
    The drain always reconstructs deferred messages with src: and reply: prefixes. That only matches the product inbound path; WebUI and runtime/task submissions persist different raw binding ids that are rebuilt with source-specific prefixes such as webui-src/webui-reply or already-stable runtime refs. A DeferredBusy WebUI/runtime message can therefore resume with different binding refs than the original submission path would have used, risking misrouted follow-up interactions.
    Also flagged by: maintainability/Medium, approach/Medium.
  2. Medium Invalid deferred messages can permanently hide valid later ones (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:97-100, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:100
    Each drain only loads the oldest 8 DeferredBusy messages, while validation failures leave those records unchanged. If the first 8 deferred records are invalid, the loop skips them all, submits nothing, and the next terminal event fetches the same 8 records again, so any valid message after them is never reached.

performance

  1. Medium Drain limit is applied after a full transcript scan (crates/ironclaw_threads/src/filesystem_service.rs:904-912, confidence 88) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:904
    list_deferred_busy_messages accepts limit, and the drain calls it with Some(8), but the filesystem backend first calls list_thread_messages, which lists, reads, deserializes, collects, and sorts every message JSON in the thread before truncating. Normal terminal events now add O(total thread messages) filesystem I/O even when there are zero deferred messages; draining N queued messages cascades into N full transcript scans.

tests

  1. High Production drain wiring lacks a caller-level test (crates/ironclaw_reborn_composition/src/runtime.rs:2149-2226, confidence 100) — anchor: crates/ironclaw_reborn_composition/src/runtime.rs:2215
    The new DeferredBusyDrainObserver is wired into build_reborn_runtime, but the existing drain tests manually construct and subscribe the observer. The adjacent e2e file is only a no-op redirect, so a regression that drops additional_required_observers or fails bind_coordinator in the production runtime path would not be caught.
  2. Medium Cascade semantics are not tested with multiple valid messages (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:296-298, confidence 100) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:296
    The drain stops after the first successful submit to enforce one-at-a-time cascade behavior, but the tests only cover a single valid deferred message or an invalid head followed by one valid message. There is no test with two valid DeferredBusy messages proving the first terminal event submits only the oldest and the next terminal event drains the next.
  3. Medium List failure path is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:95-112, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:105
    The observer catches list_deferred_busy_messages failures and returns Ok so terminal run publication is not poisoned, but no adjacent drain test exercises a SessionThreadService list error. The PR explicitly promises that list errors leave messages deferred and do not fail the terminal path.
  4. Medium Re-busy drain contention branch is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:300-310, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:300
    The drain has a ThreadBusy branch for the case where another run reacquires the thread lock before the deferred message can be resubmitted, but existing tests only exercise ThreadBusy while initially marking messages deferred. No test covers the drain attempt itself hitting ThreadBusy and leaving the message DeferredBusy for retry on the next terminal event.

local-patterns

  1. Low Module docs use item docs instead of inner docs (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:1-17, confidence 75) (no diff position - body only) — anchor: crates/ironclaw_reborn_composition/src/runtime.rs:1
    The new file starts with /// comments before a use, so the overview documents the import item instead of the module. Nearby composition modules put file-level overviews in //! inner docs, which keeps rustdoc and source navigation attached to the module itself.
  2. Nit Tracing dependency comment is stale against Cargo (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:497-500, confidence 50) (no diff position - body only) — anchor: crates/ironclaw_reborn_composition/Cargo.toml:163
    The comment says tracing is available transitively via other crates, but this crate already declares tracing = "0.1" directly. That makes the dependency explanation misleading during future cleanup and is unnecessary for a normal import.

// Use the same bounded conversion that the inbound turn path used
// when originally persisting the binding ids, so the ref matches
// what the original product submission produced.
let source_binding_ref = match bounded_source_binding_ref(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High — Drain rewrites non-product binding refs with product prefixes.

The drain always reconstructs deferred messages with src: and reply: prefixes. That only matches the product inbound path; WebUI and runtime/task submissions persist different raw binding ids that are rebuilt with source-specific prefixes such as webui-src/webui-reply or already-stable runtime refs. A DeferredBusy WebUI/runtime message can therefore resume with different binding refs than the original submission path would have used, risking misrouted follow-up interactions.

Fix: Persist/replay the canonical refs or move the resubmission handoff behind the product-workflow owner so each source reconstructs refs with its original prefix/path.

Also flagged by: maintainability/Medium, approach/Medium

.list_deferred_busy_messages(ListDeferredBusyMessagesRequest {
scope: thread_scope.clone(),
thread_id: scope.thread_id.clone(),
limit: Some(DRAIN_LIST_LIMIT),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Invalid deferred messages can permanently hide valid later ones.

Each drain only loads the oldest 8 DeferredBusy messages, while validation failures leave those records unchanged. If the first 8 deferred records are invalid, the loop skips them all, submits nothing, and the next terminal event fetches the same 8 records again, so any valid message after them is never reached.

Fix: Either drain without this fixed prefix limit, paginate past skipped invalid records, or mark invalid records as non-drainable so later valid messages can progress.

if !thread_exists {
return Ok(Vec::new());
}
let mut messages: Vec<ThreadMessageRecord> = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Drain limit is applied after a full transcript scan.

list_deferred_busy_messages accepts limit, and the drain calls it with Some(8), but the filesystem backend first calls list_thread_messages, which lists, reads, deserializes, collects, and sorts every message JSON in the thread before truncating. Normal terminal events now add O(total thread messages) filesystem I/O even when there are zero deferred messages; draining N queued messages cascades into N full transcript scans.

Fix: Maintain a per-thread deferred-busy index/queue updated by mark_message_deferred_busy and mark_message_submitted, then read only the first limit entries instead of materializing the whole transcript.

hook_security_audit_sink: Some(Arc::new(ironclaw_events::TracingSecurityAuditSink)),
turn_event_sink: None,
hook_dispatcher_builder_factory,
additional_required_observers: vec![drain_observer],

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High — Production drain wiring lacks a caller-level test.

The new DeferredBusyDrainObserver is wired into build_reborn_runtime, but the existing drain tests manually construct and subscribe the observer. The adjacent e2e file is only a no-op redirect, so a regression that drops additional_required_observers or fails bind_coordinator in the production runtime path would not be caught.

Fix: tests::deferred_busy_drain_e2e::runtime_drains_deferred_busy_after_gate_terminal covering product/runtime inbound DeferredBusy message, blocking run terminal completion, and resubmitted message status

"DeferredBusyDrainObserver: submitted to coordinator but failed to mark message as submitted"
);
}
// Stop after the first successful submit — the cascade

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Cascade semantics are not tested with multiple valid messages.

The drain stops after the first successful submit to enforce one-at-a-time cascade behavior, but the tests only cover a single valid deferred message or an invalid head followed by one valid message. There is no test with two valid DeferredBusy messages proving the first terminal event submits only the oldest and the next terminal event drains the next.

Fix: tests::deferred_busy_drain::drain_submits_one_valid_message_per_terminal_event_cascade covering two valid DeferredBusy messages, oldest-first submission, and second-message drain after the first drained run terminates

.await
{
Ok(messages) => messages,
Err(error) => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — List failure path is untested.

The observer catches list_deferred_busy_messages failures and returns Ok so terminal run publication is not poisoned, but no adjacent drain test exercises a SessionThreadService list error. The PR explicitly promises that list errors leave messages deferred and do not fail the terminal path.

Fix: tests::deferred_busy_drain::drain_list_error_returns_ok_and_leaves_deferred covering a failing list_deferred_busy_messages service and observe_committed_event/observe_committed_state returning Ok

// will handle subsequent messages when this run terminates.
return Ok(());
}
Err(TurnError::ThreadBusy(busy)) => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Re-busy drain contention branch is untested.

The drain has a ThreadBusy branch for the case where another run reacquires the thread lock before the deferred message can be resubmitted, but existing tests only exercise ThreadBusy while initially marking messages deferred. No test covers the drain attempt itself hitting ThreadBusy and leaving the message DeferredBusy for retry on the next terminal event.

Fix: tests::deferred_busy_drain::drain_leaves_deferred_when_resubmit_hits_thread_busy covering coordinator submit_turn returning ThreadBusy during drain

henrypark133 added a commit that referenced this pull request Jun 12, 2026
…new tests

1. Unbounded detached posts: add `hint_post_permits` semaphore
   (MAX_CONCURRENT_HINT_POSTS = 4) to `SlackFinalReplyDeliveryObserver`.
   `try_acquire_owned()` before spawning; drop hint with debug! on
   saturation so a burst cannot create unbounded tasks or bypass
   max_concurrent_deliveries. Permit held for the full duration of the
   Slack I/O inside the spawn.

2. Generic copy honesty: rewrite SLACK_DEFERRED_BUSY_GENERIC_MESSAGE to
   "...can't take this one yet — please resend it once the current task
   finishes." (no false queue promise); add comment to revisit when
   deferred-drain PR #4812 lands.

3. Helper API: replace `is_deferred_busy_user_message(…) -> bool` +
   unreachable! extraction with `deferred_busy_user_message_run_id(…) ->
   Option<TurnRunId>`; caller uses `if let Some(run_id) = …`.

4. New test: deferred_busy_missing_agent_binding_posts_generic_hint —
   binding with agent_id=None → scope derivation fails → generic copy.

5. New test: deferred_busy_run_state_lookup_error_posts_generic_hint —
   ErroringTurnCoordinator always returns Err → generic copy.

6. New test: deferred_busy_post_failure_is_best_effort — egress
   programmed with Err(Timeout) → no panic, ack path returns normally.
   Bonus: deferred_busy_semaphore_saturated_drops_hint_without_panic —
   all permits pre-held → no post, no panic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
henrypark133 and others added 3 commits June 12, 2026 10:54
…, and presence-marker fast path

- ThreadMessageRecord gains turn_source_binding_ref / turn_reply_target_binding_ref (Option<String>)
  persisted at mark_message_deferred_busy time; drain replays these verbatim
- ListDeferredBusyMessagesRequest gains after_sequence: Option<u64> for windowed pagination
- InMemorySessionThreadService: deferred_threads HashSet as O(1) presence marker;
  tombstone on empty scan
- FilesystemSessionThreadService: deferred-busy.marker written before status flip,
  checked as fast-path, deleted on empty scan
- Contract tests updated for new params and new marker-lifecycle assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Record sites for new fields

- inbound_turn.rs: capture canonical_source_ref / canonical_reply_ref strings before
  SubmitTurnRequest moves them, pass to mark_message_deferred_busy in ThreadBusy arm
- reborn_services, compaction_task, loop_exit_applier tests, completion_observer,
  trigger_poller_trusted_submit: add turn_source_binding_ref/turn_reply_target_binding_ref: None
  and after_sequence: None to all ThreadMessageRecord literals and ListDeferredBusyMessagesRequest

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pagination, and cap

Production changes:
- drain_for_terminal_state reads turn_source/reply_target_binding_ref from record and
  skips (warn+continue) legacy/None records — fixes wrong-prefix reconstruction
- DRAIN_LIST_LIMIT = 8 window size; DRAIN_TOTAL_CAP = 64 max messages per terminal event
- Windowed pagination: after_sequence advances past all-invalid windows; terminates at cap

Test additions (deferred_busy_drain::tests):
- drain_submits_using_canonical_refs_persisted_at_defer_time (finding 1)
- drain_submits_one_valid_message_per_terminal_event_cascade (finding 5)
- drain_list_error_returns_ok_and_leaves_deferred (finding 6, FailingListService)
- drain_leaves_deferred_when_resubmit_hits_thread_busy (finding 7)

runtime::tests:
- runtime_drains_deferred_busy_after_gate_terminal: production-wiring test via
  build_reborn_runtime + stop_turn_runner_worker_for_manual_state_test (finding 4)

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

Copy link
Copy Markdown
Collaborator Author

Second-round comments addressed in f45a562..6757cc4 (the 14:4x batch re-reported pre-push findings already fixed in 37dbd35; the 16:57 batch was fresh):

  • Drain rewrote non-product binding refs with product prefixes (High): the drain no longer reconstructs refs at all. The canonical, already-bounded turn refs are now persisted on the message record at defer time (turn_source_binding_ref/turn_reply_target_binding_ref, #[serde(default)] — old records rehydrate as None) by every DeferredBusy-marking path, and replayed verbatim. Records without canonical refs (legacy shape) are skipped with a warning — acceptable pre-merge. This kills the per-source prefix divergence class (src:/webui-src:/runtime refs) rather than special-casing it.
  • Invalid heads could hide valid messages behind the window (Medium): the drain now paginates — after_sequence on the list request; fully-skipped windows advance to the next, bounded by a documented total-examined cap. No status mutation of skipped records (retention rule), consistent with the earlier decision against sentinel statuses.
  • Full transcript scan per terminal event (Medium): presence-marker fast path. The filesystem backend writes a per-thread marker before flipping a message to DeferredBusy (conservative ordering: stale marker = one wasted scan; missing marker with deferred messages can't happen); list_deferred_busy_messages returns empty without scanning when the marker is absent and clears it when a scan finds nothing. Zero-deferred terminal events — the common case — no longer touch the transcript.
  • Production wiring untested (High): runtime_drains_deferred_busy_after_gate_terminal drives the real runtime build path — failing if additional_required_observers wiring or bind_coordinator regresses.
  • Missing tests: drain_submits_one_valid_message_per_terminal_event_cascade, drain_list_error_returns_ok_and_leaves_deferred, drain_leaves_deferred_when_resubmit_hits_thread_busy added.

Note for #4811 coupling: with the drain real, #4811's deferred-busy generic copy can be restored to queued-wording once both merge.

threads + composition suites green, clippy zero warnings, workspace check clean.

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_threads/src/in_memory.rs (1)

663-685: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove the deferred-busy marker when a thread is deleted.

mark_message_deferred_busy inserts (ThreadScope, ThreadId) into deferred_threads, but delete_thread only removes the thread record and idempotency entries. Any thread that ever hit DeferredBusy leaves a dead marker behind, so create/delete churn can grow this HashSet without bound.

As per coding guidelines, "User-controlled inputs must not grow unbounded. Apply hard size limits (entries + total bytes) with documented eviction policies on interners, caches, and accumulators."

Suggested cleanup
         state.threads.remove(thread_id);
+        state
+            .deferred_threads
+            .remove(&(scope.clone(), thread_id.clone()));
         state
             .inbound_idempotency
             .retain(|_, record| &record.thread_id != thread_id);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_threads/src/in_memory.rs` around lines 663 - 685,
delete_thread currently removes the thread record and inbound_idempotency
entries but leaves deferred_threads entries behind; update the delete_thread
implementation so after removing the thread from state.threads it also removes
any deferred-busy marker for that thread by removing (scope, thread_id) from
state.deferred_threads (the same HashSet that mark_message_deferred_busy writes
to); do this while holding the same state lock so no races occur and ensure you
reference state.deferred_threads, delete_thread, and mark_message_deferred_busy
when making the change.

Source: Coding guidelines

🧹 Nitpick comments (2)
crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs (1)

1114-1394: ⚡ Quick win

Add a contract case for after_sequence.

The new deferred-busy cursor is part of the service contract, but this suite only exercises after_sequence: None. A backend can ignore the pagination/skip semantics and still pass every test here. Seed multiple deferred messages and assert that after_sequence skips the older window before ordering and limit are applied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs` around
lines 1114 - 1394, Add a new test (e.g.
filesystem_list_deferred_busy_messages_respects_after_sequence) that seeds
multiple deferred-busy messages via
FilesystemSessionThreadService::accept_inbound_message and
mark_message_deferred_busy, then call list_deferred_busy_messages with
ListDeferredBusyMessagesRequest specifying after_sequence: Some(x) (pick x to
skip the oldest N messages) and optional limit, and assert that every returned
message has message.sequence > x, that results are ordered ascending by
sequence, and that limit is applied; use the existing helpers
scoped_threads_fs_at, scope(...), and the same service methods
(accept_inbound_message, mark_message_deferred_busy,
list_deferred_busy_messages) and assert MessageStatus::DeferredBusy and
MessageKind where appropriate.
crates/ironclaw_threads/tests/session_thread_contract.rs (1)

2518-2902: ⚡ Quick win

Add explicit contract coverage for after_sequence pagination semantics.

The new section exercises limit/ordering well, but every request uses after_sequence: None (e.g., Line 2537, Line 2597, Line 2646). Add one test that sets after_sequence and verifies strict exclusion of older/equal sequences plus correct ordering/limit behavior in the returned window.

Suggested test shape
+#[tokio::test]
+async fn list_deferred_busy_messages_after_sequence_filters_and_orders() {
+    let service = InMemorySessionThreadService::default();
+    let thread = service
+        .ensure_thread(EnsureThreadRequest {
+            scope: scope("after-seq"),
+            thread_id: None,
+            created_by_actor_id: "actor-a".into(),
+            title: None,
+            metadata_json: None,
+        })
+        .await
+        .unwrap();
+
+    for label in ["m1", "m2", "m3", "m4"] {
+        let msg = service
+            .accept_inbound_message(AcceptInboundMessageRequest {
+                scope: scope("after-seq"),
+                thread_id: thread.thread_id.clone(),
+                actor_id: "actor-a".into(),
+                source_binding_id: None,
+                reply_target_binding_id: None,
+                external_event_id: None,
+                content: user_message(label),
+            })
+            .await
+            .unwrap();
+        service
+            .mark_message_deferred_busy(
+                &scope("after-seq"),
+                &thread.thread_id,
+                msg.message_id,
+                None,
+                None,
+            )
+            .await
+            .unwrap();
+    }
+
+    let page = service
+        .list_deferred_busy_messages(ListDeferredBusyMessagesRequest {
+            scope: scope("after-seq"),
+            thread_id: thread.thread_id,
+            limit: Some(2),
+            after_sequence: Some(2),
+        })
+        .await
+        .unwrap();
+
+    let seqs: Vec<_> = page.iter().map(|m| m.sequence).collect();
+    assert_eq!(seqs, vec![3, 4]);
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_threads/tests/session_thread_contract.rs` around lines 2518 -
2902, Add a new tokio test (e.g.,
list_deferred_busy_messages_after_sequence_excludes_and_limits) that creates an
InMemorySessionThreadService and thread, accepts and defers several messages via
accept_inbound_message and mark_message_deferred_busy, captures their sequence
numbers, then calls list_deferred_busy_messages with
ListDeferredBusyMessagesRequest using after_sequence set to one of the captured
sequences and asserts the returned window excludes messages with sequence <=
after_sequence, is ordered strictly ascending by message.sequence, and respects
limit (exercise with limit=None and limit=Some(1)); reference
accept_inbound_message, mark_message_deferred_busy, list_deferred_busy_messages,
ListDeferredBusyMessagesRequest and the message.sequence field to locate the
logic to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs`:
- Around line 107-147: The per-message loop in DeferredBusyDrainObserver can
exceed DRAIN_TOTAL_CAP because total_examined is only checked between page
fetches; inside the for message in window loop you must enforce the cap by
checking total_examined >= DRAIN_TOTAL_CAP before processing each message and
breaking out of both the per-message loop and outer loop (returning Ok(() ) or
jumping to the same exit path) when the cap is hit. Update the logic around
total_examined in the for message in window iteration to stop processing further
messages and avoid inspecting beyond the hard cap, ensuring any necessary
bookkeeping (e.g., window_last_sequence/after_sequence) matches the existing
exit behavior.

In `@crates/ironclaw_threads/src/contract.rs`:
- Around line 130-141: ThreadMessageRecord currently exposes internal
replay-only fields turn_source_binding_ref and turn_reply_target_binding_ref
which will leak routing metadata to public reads; remove or hide them from the
public DTO by moving these fields into a backend-only persisted shape (e.g., a
separate InternalThreadMessageRecord / StoredThreadMessageRecord) or marking
them to be skipped for public serialization and ensure public read paths return
the scrubbed ThreadMessageRecord; update any code that currently copies those
fields into ThreadMessageRecord (search for usages of turn_source_binding_ref
and turn_reply_target_binding_ref) to instead record them only in the
backend-only struct and/or populate them when the drain reads from the
backend-only representation.

---

Outside diff comments:
In `@crates/ironclaw_threads/src/in_memory.rs`:
- Around line 663-685: delete_thread currently removes the thread record and
inbound_idempotency entries but leaves deferred_threads entries behind; update
the delete_thread implementation so after removing the thread from state.threads
it also removes any deferred-busy marker for that thread by removing (scope,
thread_id) from state.deferred_threads (the same HashSet that
mark_message_deferred_busy writes to); do this while holding the same state lock
so no races occur and ensure you reference state.deferred_threads,
delete_thread, and mark_message_deferred_busy when making the change.

---

Nitpick comments:
In `@crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs`:
- Around line 1114-1394: Add a new test (e.g.
filesystem_list_deferred_busy_messages_respects_after_sequence) that seeds
multiple deferred-busy messages via
FilesystemSessionThreadService::accept_inbound_message and
mark_message_deferred_busy, then call list_deferred_busy_messages with
ListDeferredBusyMessagesRequest specifying after_sequence: Some(x) (pick x to
skip the oldest N messages) and optional limit, and assert that every returned
message has message.sequence > x, that results are ordered ascending by
sequence, and that limit is applied; use the existing helpers
scoped_threads_fs_at, scope(...), and the same service methods
(accept_inbound_message, mark_message_deferred_busy,
list_deferred_busy_messages) and assert MessageStatus::DeferredBusy and
MessageKind where appropriate.

In `@crates/ironclaw_threads/tests/session_thread_contract.rs`:
- Around line 2518-2902: Add a new tokio test (e.g.,
list_deferred_busy_messages_after_sequence_excludes_and_limits) that creates an
InMemorySessionThreadService and thread, accepts and defers several messages via
accept_inbound_message and mark_message_deferred_busy, captures their sequence
numbers, then calls list_deferred_busy_messages with
ListDeferredBusyMessagesRequest using after_sequence set to one of the captured
sequences and asserts the returned window excludes messages with sequence <=
after_sequence, is ordered strictly ascending by message.sequence, and respects
limit (exercise with limit=None and limit=Some(1)); reference
accept_inbound_message, mark_message_deferred_busy, list_deferred_busy_messages,
ListDeferredBusyMessagesRequest and the message.sequence field to locate the
logic to test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b6749f9-abbf-44ae-b953-9d9acbef5338

📥 Commits

Reviewing files that changed from the base of the PR and between 72ad241 and 6757cc4.

📒 Files selected for processing (17)
  • crates/ironclaw_loop_support/src/compaction_task.rs
  • crates/ironclaw_loop_support/src/subagent_spawn_port/tests.rs
  • crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs
  • crates/ironclaw_product_workflow/src/inbound_turn.rs
  • crates/ironclaw_product_workflow/src/reborn_services.rs
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn/src/loop_exit_applier/tests/mod.rs
  • crates/ironclaw_reborn/src/subagent/completion_observer.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs
  • crates/ironclaw_threads/src/contract.rs
  • crates/ironclaw_threads/src/filesystem_service.rs
  • crates/ironclaw_threads/src/in_memory.rs
  • crates/ironclaw_threads/src/service.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs
  • crates/ironclaw_threads/tests/session_thread_contract.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs
  • crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs

Comment thread crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs Outdated
Comment thread crates/ironclaw_threads/src/contract.rs

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain DeferredBusy messages after a gate-blocked run reaches terminal state so blocked-thread messages are not swallowed.

Stats: 11 findings (from 13 raw, 11 after dedup/filter) across 6 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 1.

Performance / Concurrency

  1. High Marker can be deleted before the deferred write commits (crates/ironclaw_threads/src/filesystem_service.rs:891-899, confidence 88) - anchor: crates/ironclaw_threads/src/filesystem_service.rs:891
    The filesystem backend writes the DeferredBusy presence marker before the message status is flipped, then deletes that marker on an empty scan. A terminal event in that await gap can leave a DeferredBusy message markerless and permanently skipped by future drains.

Bugs

  1. Medium Drained WebUI messages bypass skill activation recording (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:270-285, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:285
    WebUI records activation before coordinator submit, clears it on ThreadBusy, and the drain later calls TurnCoordinator directly. Deferred slash/mention skill messages can replay without the activation side effect they get on the immediate path.

Tests

  1. Medium Product inbound defer path does not assert persisted refs (crates/ironclaw_product_workflow/src/inbound_turn.rs:650-658, confidence 75) - anchor: crates/ironclaw_product_workflow/src/inbound_turn.rs:651
    The product ThreadBusy path now persists canonical refs, but the busy-path contract can still pass if those refs are dropped or swapped.
  2. Medium WebUI defer path does not assert persisted refs (crates/ironclaw_product_workflow/src/reborn_services.rs:1764-1774, confidence 75) - anchor: crates/ironclaw_product_workflow/src/reborn_services.rs:1766
    The WebUI ThreadBusy path computes and passes canonical refs, but tests do not read the stored DeferredBusy message fields.
  3. Medium In-memory deferred listing lacks after_sequence coverage (crates/ironclaw_threads/src/in_memory.rs:284-292, confidence 100) - anchor: crates/ironclaw_threads/src/in_memory.rs:284
    The drain depends on after_sequence paging past invalid windows, but in-memory contract tests only exercise None.
  4. Medium Filesystem deferred listing lacks after_sequence coverage (crates/ironclaw_threads/src/filesystem_service.rs:929-938, confidence 100) - anchor: crates/ironclaw_threads/src/filesystem_service.rs:929
    The filesystem backend implements the same after_sequence paging filter, but its contract tests only exercise None.
  5. Medium Drain identity skip path is not covered (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:151-160, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:151
    The PR promises actor-less records remain deferred instead of being submitted as the owner, but there is no test covering that branch.

Conventions

  1. Medium Remove unused public binding-ref re-exports (crates/ironclaw_product_workflow/src/lib.rs:84-86, confidence 100) - anchor: CLAUDE.md:20
    The drain reconstructs persisted canonical refs directly and does not consume these helpers, so this widens product_workflow's public API without a downstream consumer. Also flagged by maintainability/Low.

Local Patterns

  1. Low Comment bakes branch-temporary context into runtime code (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:164-166, confidence 75) - anchor: crates/ironclaw_reborn/src/subagent/completion_observer.rs:83
    The comment references an unmerged branch state instead of a durable invariant.
  2. Nit Tracing macro import is buried outside the import block (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:527-530, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs:1
    The tracing macro import is late in the file, unlike sibling composition modules.

Maintainability

  1. Low Drain paging and request construction are fused into one branch-heavy loop (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:151-285, confidence 50) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:151 (no diff position - body only)
    The loop mixes paging, skip policy, request construction, submission, and reconciliation. Consider extracting a per-message projection helper if this area changes further.

.ok_or_else(|| SessionThreadError::UnknownThread {
thread_id: thread_id.clone(),
})?;
// Write presence marker BEFORE flipping the message status so that

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High - Marker can be deleted before the deferred write commits.

The filesystem backend writes the DeferredBusy presence marker before the message status is flipped, then list_deferred_busy_messages deletes that marker whenever a scan finds no DeferredBusy rows. If the blocking run terminates between the marker write and apply_message_update, the drain can observe the marker, scan before the status change, delete the marker, and then the later status update leaves a DeferredBusy message that future terminal drains skip via the marker fast path.

Fix: Do not make marker deletion race the status write. Write the marker only after the DeferredBusy status/ref fields commit, make the marker update atomic with the message update, or avoid deleting the marker on an empty scan when a defer write may still be in flight.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 02f1f18 — see crates/ironclaw_threads/src/filesystem_service.rs.

spawn_tree_root_run_id: None,
};

match coordinator.submit_turn(request).await {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drained WebUI messages bypass skill activation recording.

The WebUI submit path records skill activation before submitting to the coordinator, but clears that pending activation on ThreadBusy before marking the message DeferredBusy. The drain later submits the persisted message directly through TurnCoordinator, so a deferred slash/mention skill message is replayed without the skill-activation side effect that the same message gets on the immediate submit path.

Fix: Replay drained WebUI/product messages through a product-aware hook that records activation again, or persist enough activation metadata at defer time and restore it before submit_turn.

&thread_scope,
&binding.thread_id,
message_id,
Some(canonical_source_ref),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Product inbound defer path does not assert persisted refs.

The ThreadBusy branch now persists canonical source/reply binding refs for later drain replay, but the adjacent busy-thread contract only checks DeferredBusy status. If either argument is dropped or swapped, the drain skips the message and the test still passes.

Fix: Add caller-level coverage for the ThreadBusy path that reads the stored message and asserts turn_source_binding_ref and turn_reply_target_binding_ref are persisted with the expected canonical values.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs.

&thread_scope,
&handoff,
&client_action_id,
canonical_source_ref,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - WebUI defer path does not assert persisted refs.

The WebUI ThreadBusy branch computes canonical webui refs and passes them into mark_message_deferred_busy, but the busy-path tests do not read the stored message fields. A regression that omits these refs would leave WebUI DeferredBusy messages undrainable without failing tests.

Fix: Add a reborn_services busy-path test that verifies DeferredBusy messages persist the canonical webui source and reply refs.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_product_workflow/tests/reborn_services_contract.rs.

Some(thread) if thread.record.scope == request.scope => thread,
_ => return Ok(Vec::new()),
};
let after_seq = request.after_sequence.unwrap_or(0);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - In-memory deferred listing lacks after_sequence coverage.

list_deferred_busy_messages filters by sequence after an already-examined window, but the in-memory contract tests only pass after_sequence: None. If this filter regresses, the drain can keep seeing the same invalid window and never reach later valid deferred messages.

Fix: Add contract coverage where after_sequence is Some(seq) and only later DeferredBusy records are returned.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_threads/tests/session_thread_contract.rs.

if !marker_present {
return Ok(Vec::new());
}
let after_seq = request.after_sequence.unwrap_or(0);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Filesystem deferred listing lacks after_sequence coverage.

The filesystem backend implements the same after_sequence paging filter used by the drain, but its contract tests only exercise None. A backend-specific mistake here would break drain pagination for persisted threads.

Fix: Add filesystem contract coverage where after_sequence is Some(seq) and only later DeferredBusy records are returned.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs.

// Build the coordinator submission from the thread record fields.
// On any validation failure for this message, log + skip to next.

let actor_user_id = match resolve_actor_user_id(&message, thread_scope) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drain identity skip path is not covered.

The PR explicitly promises that a deferred record without actor_id remains deferred rather than being resubmitted as the thread owner, but the drain tests only cover missing canonical refs and successful actor resolution. The actor-missing branch can regress without a failing test.

Fix: Add a drain test that creates a DeferredBusy message with actor_id None, fires the terminal observer, and asserts no submission occurs and the message remains DeferredBusy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs.

ConversationBindingService, ProductConversationRouteKind, ResolveBindingRequest,
ResolvedBinding, route_kind_for_inbound_payload,
};
pub use binding_ref::{

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Remove unused public binding-ref re-exports.

This adds pub re-exports for binding-ref helpers, but the drain observer reconstructs persisted canonical refs directly and does not consume these helpers. That broadens product_workflow's public API without a downstream consumer in this diff.

Fix: Keep the binding-ref helpers crate-private and remove the lib.rs re-export unless the drain actually routes through the bounded conversion helpers.

Also flagged by: maintainability/Low

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_product_workflow/src/lib.rs.

}
};

// Use the canonical refs persisted at defer time. Records written

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low - Comment bakes branch-temporary context into runtime code.

The comment explains missing binding refs as a legacy unmerged-branch condition. After this lands, that wording becomes stale review context rather than a durable invariant.

Fix: Reword the comment to a stable rule, such as records persisted before canonical binding refs existed have None and are skipped.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs.

}
}

// Tracing macros used in this module come from the `tracing` crate which is

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nit - Tracing macro import is buried outside the import block.

This new module imports tracing macros after helper functions with a dependency note, while sibling composition modules either keep imports at the top or call tracing::debug!/warn! directly.

Fix: Move the tracing import into the top use block, or use fully qualified tracing::debug! and tracing::warn! like sibling composition files.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f2600cc — see crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs.

henrypark133 added a commit that referenced this pull request Jun 12, 2026
…new tests

1. Unbounded detached posts: add `hint_post_permits` semaphore
   (MAX_CONCURRENT_HINT_POSTS = 4) to `SlackFinalReplyDeliveryObserver`.
   `try_acquire_owned()` before spawning; drop hint with debug! on
   saturation so a burst cannot create unbounded tasks or bypass
   max_concurrent_deliveries. Permit held for the full duration of the
   Slack I/O inside the spawn.

2. Generic copy honesty: rewrite SLACK_DEFERRED_BUSY_GENERIC_MESSAGE to
   "...can't take this one yet — please resend it once the current task
   finishes." (no false queue promise); add comment to revisit when
   deferred-drain PR #4812 lands.

3. Helper API: replace `is_deferred_busy_user_message(…) -> bool` +
   unreachable! extraction with `deferred_busy_user_message_run_id(…) ->
   Option<TurnRunId>`; caller uses `if let Some(run_id) = …`.

4. New test: deferred_busy_missing_agent_binding_posts_generic_hint —
   binding with agent_id=None → scope derivation fails → generic copy.

5. New test: deferred_busy_run_state_lookup_error_posts_generic_hint —
   ErroringTurnCoordinator always returns Err → generic copy.

6. New test: deferred_busy_post_failure_is_best_effort — egress
   programmed with Err(Timeout) → no panic, ack path returns normally.
   Bonus: deferred_busy_semaphore_saturated_drops_hint_without_panic —
   all permits pre-held → no post, no panic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@henrypark133

Copy link
Copy Markdown
Collaborator Author

Follow-ups filed from review: #4831 (route drain resubmission through product_workflow replay owner — also resolves the skill-activation bypass), #4832 (batch drained messages into one run, depends on #4831), #4833 (per-thread deferred index for filesystem scan cost).

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain and resubmit DeferredBusy messages once the blocking run reaches a terminal state.
Stats: 11 findings (from 14 raw, 11 after dedup) across 4 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0

security

  1. Medium Deferred drain limit still scans the entire transcript (crates/ironclaw_threads/src/filesystem_service.rs:938-950, confidence 75) - anchor: crates/ironclaw_threads/src/filesystem_service.rs:938
    The new drain path passes limit=8, but the filesystem backend first calls list_thread_messages(), which lists, reads, deserializes, and sorts every message in the thread before filtering DeferredBusy rows and truncating. A large thread can therefore turn each terminal event that triggers the drain into an O(thread size) filesystem scan despite the small drain cap.
    Also flagged by: performance/Medium

bugs

  1. Medium Marker write failure can hide a deferred message forever (crates/ironclaw_threads/src/filesystem_service.rs:908-916, confidence 75) - anchor: crates/ironclaw_threads/src/filesystem_service.rs:908
    The filesystem backend first persists the message as DeferredBusy and only then writes deferred-busy.marker. If that marker write fails, the caller sees a transient error but the message is already durable as DeferredBusy without the marker. Future drain attempts take the marker-absent fast path and return an empty list, so the message is never retried unless the user happens to resubmit/replay it.
  2. Medium Invalid deferred records can permanently starve later messages (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:109-115, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:109
    Each drain invocation starts at after_sequence = None and stops after examining 64 records. Because skipped invalid or legacy records are not mutated and no cursor is persisted across invocations, a thread with 64 invalid DeferredBusy rows before a valid one will re-scan the same 64 rows on every terminal event and never reach the valid message behind them.

tests

  1. Medium Drain submit-error branch is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:340-347, confidence 100) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:340
    The drain's failure contract says coordinator submit failures must log, return Ok, and leave the message deferred, but the adjacent tests only cover success, list errors, invalid records, and ThreadBusy. A non-ThreadBusy submit error could regress into poisoning the terminal path without a test catching it.
  2. Medium Post-submit persistence failure is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:307-323, confidence 100) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:307
    After the coordinator accepts a drained turn, mark_message_submitted can fail and the observer is supposed to warn without failing the terminal transition. No adjacent drain test injects that persistence error, so the no-poisoning guarantee is uncovered for this branch.

conventions

  1. Medium Keep internal drain refs out of facade timeline DTOs (crates/ironclaw_threads/src/contract.rs:130-141, confidence 75) - anchor: crates/ironclaw_product_workflow/AGENTS.md:18
    The new serde-visible turn_source_binding_ref and turn_reply_target_binding_ref fields are internal drain-replay metadata, but RebornTimelineResponse still returns Vec from the product-workflow facade. The WebUI handler scrubs one HTTP route, but direct RebornServicesApi::get_timeline consumers and future facade routes can still receive the enriched substrate record.

local-patterns

  1. Low Module overview is attached as an outer doc comment (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:1-16, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/runtime.rs:1
    This file starts with /// comments, which document the following use item rather than the module. Nearby composition modules put file-level overviews in inner //! comments, so this new overview will not show up as module documentation.
  2. Nit Comment describes an unreachable post-submit path (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:350-351, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:350
    The comment says this point is reached after validation skips, but validation skips use continue before the submit match and every submit-match arm returns. That leaves misleading guide text in the core drain loop.

maintainability

  1. Medium Drain duplicates the canonical thread-scope rule (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:480-517, confidence 75) - anchor: crates/ironclaw_reborn/src/thread_scope.rs:9
    The new drain reconstructs ThreadScope from TurnLifecycleEvent and TurnRunState with local owner-fallback rules. ironclaw_reborn::thread_scope documents ThreadScopeResolver as the single owner-rewrite rule because hand-rolled copies can silently drift, and the new conversion now adds another copy in composition.
  2. Medium DeferredBusy writes allow invalid new records (crates/ironclaw_threads/src/service.rs:46-53, confidence 75) - anchor: crates/ironclaw_threads/src/contract.rs:130
    The storage record needs optional turn binding refs for legacy rows, but the write boundary now accepts Option too. New DeferredBusy records can still be created in the legacy shape, and the drain will keep skipping those messages because the real contract lives only in callers.
  3. Low New drain module lands as a 1,982-line file (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:540-541, confidence 100) - anchor: .claude/rules/architecture.md:186
    The new production observer and its large test harness are in one file, pushing a brand-new module past the repo's large-file threshold. Roughly three quarters of the file is inline test scaffolding and scenarios, which makes the production drain implementation harder to review and navigate.
    Also flagged by: conventions/Low

triggering_run_id = %run_id,
"DeferredBusyDrainObserver: deferred message drained and submitted"
);
if let Err(error) = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Post-submit persistence failure is untested.

After the coordinator accepts a drained turn, mark_message_submitted can fail and the observer is supposed to warn without failing the terminal transition. No adjacent drain test injects that persistence error, so the no-poisoning guarantee is uncovered for this branch.

Fix: Add a drain_mark_submitted_error_returns_ok_after_accept test covering mark_message_submitted failure after SubmitTurnResponse::Accepted.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — drain_mark_submitted_error_returns_ok_after_accept added.

);
return Ok(());
}
Err(error) => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drain submit-error branch is untested.

The drain's failure contract says coordinator submit failures must log, return Ok, and leave the message deferred, but the adjacent tests only cover success, list errors, invalid records, and ThreadBusy. A non-ThreadBusy submit error could regress into poisoning the terminal path without a test catching it.

Fix: Add a drain_submit_error_returns_ok_and_leaves_deferred_busy test covering a non-ThreadBusy TurnCoordinator::submit_turn error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — drain_submit_error_returns_ok_and_leaves_deferred_busy added.

let mut total_examined: usize = 0;

loop {
if total_examined >= DRAIN_TOTAL_CAP {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Invalid deferred records can permanently starve later messages.

Each drain invocation starts at after_sequence = None and stops after examining 64 records. Because skipped invalid or legacy records are not mutated and no cursor is persisted across invocations, a thread with 64 invalid DeferredBusy rows before a valid one will re-scan the same 64 rows on every terminal event and never reach the valid message behind them.

Fix: Persist a skip or repair state for permanently invalid records, or change the cap logic so later valid records can eventually be reached across drain invocations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred to #4831 — persisted skip/non-drainable state for permanently invalid records belongs with the product_workflow replay owner.

/// Returns `Err` with a human-readable reason when the scope cannot be derived
/// (e.g. agentless turn, missing owner). The caller handles this as a
/// non-fatal skip.
fn thread_scope_from_event(event: &TurnLifecycleEvent) -> Result<ThreadScope, &'static str> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drain duplicates the canonical thread-scope rule.

The new drain reconstructs ThreadScope from TurnLifecycleEvent and TurnRunState with local owner-fallback rules. ironclaw_reborn::thread_scope documents ThreadScopeResolver as the single owner-rewrite rule because hand-rolled copies can silently drift, and the new conversion now adds another copy in composition.

Fix: Expose or reuse the canonical ThreadScopeResolver/thread-scope conversion from the owning Reborn runtime layer and delete the local thread_scope_from_event/thread_scope_from_state copies.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — drain now uses ThreadScopeResolver::derive_for_terminal_event/state; local owner-fallback copies deleted.

/// `None` on records written before this field was added (legacy). The drain
/// warns and skips those entries.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_source_binding_ref: Option<String>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Keep internal drain refs out of facade timeline DTOs.

The new serde-visible turn_source_binding_ref and turn_reply_target_binding_ref fields are internal drain-replay metadata, but RebornTimelineResponse still returns Vec from the product-workflow facade. The WebUI handler scrubs one HTTP route, but direct RebornServicesApi::get_timeline consumers and future facade routes can still receive the enriched substrate record.

Fix: Return a product-safe timeline message type, or scrub these fields inside RebornServices::get_timeline before constructing RebornTimelineResponse.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — scrub moved into RebornServices::get_timeline so all facade consumers get scrubbed records; handler-level scrub removed; facade contract test added.

return Ok(Vec::new());
}
let after_seq = request.after_sequence.unwrap_or(0);
let mut messages: Vec<ThreadMessageRecord> = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Deferred drain limit still scans the entire transcript.

The new drain path passes limit=8, but the filesystem backend first calls list_thread_messages(), which lists, reads, deserializes, and sorts every message in the thread before filtering DeferredBusy rows and truncating. A large thread can therefore turn each terminal event that triggers the drain into an O(thread size) filesystem scan despite the small drain cap.

Fix: Back DeferredBusy listing with a per-status sequence index or queue, or otherwise honor the limit before reading and deserializing the full transcript.

Also flagged by: performance/Medium

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4833 (per-thread deferred index).

scope: &ThreadScope,
thread_id: &ThreadId,
message_id: ThreadMessageId,
turn_source_binding_ref: Option<String>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - DeferredBusy writes allow invalid new records.

The storage record needs optional turn binding refs for legacy rows, but the write boundary now accepts Option too. New DeferredBusy records can still be created in the legacy shape, and the drain will keep skipping those messages because the real contract lives only in callers.

Fix: Keep ThreadMessageRecord fields optional for old persisted data, but make mark_message_deferred_busy require canonical source and reply refs for new writes. Tests that need legacy rows should build those records directly instead of going through the write API.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred to #4831 — the replay-owner refactor touches the mark_message_deferred_busy contract; tightening new writes to require canonical refs belongs in that change.

// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low - New drain module lands as a 1,982-line file.

The new production observer and its large test harness are in one file, pushing a brand-new module past the repo's large-file threshold. Roughly three quarters of the file is inline test scaffolding and scenarios, which makes the production drain implementation harder to review and navigate.

Fix: Keep the production observer in deferred_busy_drain.rs and move the test module/harness into deferred_busy_drain/tests.rs or a focused integration test file.

Also flagged by: conventions/Low

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred — mechanical test/module split; skipping pre-merge churn.

@@ -0,0 +1,1982 @@
/// Drains [`MessageStatus::DeferredBusy`] messages when a blocking run reaches

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low - Module overview is attached as an outer doc comment.

This file starts with /// comments, which document the following use item rather than the module. Nearby composition modules put file-level overviews in inner //! comments, so this new overview will not show up as module documentation.

Fix: Change the file header from /// to //! so it follows the local module-documentation pattern.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — file overview converted to inner //! docs.

return Ok(());
}
}
// The submit succeeded — the `return Ok(())` above means we only

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nit - Comment describes an unreachable post-submit path.

The comment says this point is reached after validation skips, but validation skips use continue before the submit match and every submit-match arm returns. That leaves misleading guide text in the core drain loop.

Fix: Delete the comment, or move the explanation next to the validation continue paths it describes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ba5f6ed — comment reworded to describe the skip-only reality.

henrypark133 added a commit that referenced this pull request Jun 12, 2026
…4811)

* feat(reborn): post Slack feedback when a message is deferred behind a pending gate

When a user sends a Slack message while a run is blocked on a pending
approval/auth gate, the inbound ack is DeferredBusy and the delivery
observer silently returned — the user got no indication why their
message was ignored.

Add a best-effort one-shot hint posted before acquiring the delivery
semaphore (same pattern as the A2 rejection-hint block). The post only
fires for UserMessage payloads — resolution/control payloads stay
silent. Duplicate{DeferredBusy} unwraps to the inner ack so Slack
transport retries also post the hint exactly once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): correct DeferredBusy hint duplicate-ack semantics and message voice

- Return None for ALL Duplicate acks in deferred_busy_hint_for_user_message
  (DeferredBusy is never settled by the idempotency ledger, so
  Duplicate{DeferredBusy} is unreachable; None-for-all-Duplicate is the safe
  invariant matching rejection_hint_for_resolution)
- Document that each distinct plain DeferredBusy delivery posts a hint (desired
  feedback for users sending multiple messages while blocked; rare transport
  retries may double-post — accepted as benign best-effort)
- Replace duplicate_deferred_busy_with_user_message_posts_hint test with
  duplicate_deferred_busy_with_user_message_posts_nothing asserting the new
  None-for-all-Duplicate behavior
- Add two_distinct_deferred_busy_user_messages_post_two_hints test asserting
  the deliberate per-delivery retry semantics
- Change SLACK_DEFERRED_BUSY_MESSAGE to impersonal voice matching sibling
  constants ("Ironclaw is waiting..." instead of "I'm waiting...")

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): state-aware deferred-busy hint with authorization and detached post

Look up the blocking run's TurnStatus via turn_coordinator.get_run_state
before choosing copy for the DeferredBusy hint:
  - BlockedApproval → approval wording (approve / deny)
  - BlockedAuth     → auth wording (complete auth or auth deny)
  - anything else / lookup failure → generic queued-task wording

Add binding authorization check (mirrors post_rejection_hint_if_authorized)
so the hint is silently skipped for unauthorized conversations.

Spawn the Slack post in a detached task so a burst of deferred messages
does not pin post-ack workers on external I/O. Failure handling is
debug!-only inside the spawned task.

Replace SLACK_DEFERRED_BUSY_MESSAGE with three typed constants. Replace
deferred_busy_hint_for_user_message with is_deferred_busy_user_message
predicate and new async deferred_busy_hint_from_run_state helper.

Tests: update 2 existing DeferredBusy tests to use BlockedApproval state
and yield_now drain; add 3 new tests: BlockedAuth copy, Running/generic
copy, and unauthorized-binding suppression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): bounded hint-posts, honest copy, no unreachable!, three new tests

1. Unbounded detached posts: add `hint_post_permits` semaphore
   (MAX_CONCURRENT_HINT_POSTS = 4) to `SlackFinalReplyDeliveryObserver`.
   `try_acquire_owned()` before spawning; drop hint with debug! on
   saturation so a burst cannot create unbounded tasks or bypass
   max_concurrent_deliveries. Permit held for the full duration of the
   Slack I/O inside the spawn.

2. Generic copy honesty: rewrite SLACK_DEFERRED_BUSY_GENERIC_MESSAGE to
   "...can't take this one yet — please resend it once the current task
   finishes." (no false queue promise); add comment to revisit when
   deferred-drain PR #4812 lands.

3. Helper API: replace `is_deferred_busy_user_message(…) -> bool` +
   unreachable! extraction with `deferred_busy_user_message_run_id(…) ->
   Option<TurnRunId>`; caller uses `if let Some(run_id) = …`.

4. New test: deferred_busy_missing_agent_binding_posts_generic_hint —
   binding with agent_id=None → scope derivation fails → generic copy.

5. New test: deferred_busy_run_state_lookup_error_posts_generic_hint —
   ErroringTurnCoordinator always returns Err → generic copy.

6. New test: deferred_busy_post_failure_is_best_effort — egress
   programmed with Err(Timeout) → no panic, ack path returns normally.
   Bonus: deferred_busy_semaphore_saturated_drops_hint_without_panic —
   all permits pre-held → no post, no panic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): address five review findings on deferred-busy hint feature (#4811)

- Fix 1: remove detached tokio::spawn + hint_post_permits semaphore; await
  post_slack_message inline in observe_workflow_ack; update block comment to
  explain why inline is safe (protocol ACK already returned, runner admission
  permit bounds the whole post-ACK task per runner_immediate_ack.rs contract);
  delete the now-meaningless semaphore-saturation test

- Fix 2: add per-observer in-memory throttle (hint_seen: Mutex<...>) bounded at
  HINT_SEEN_CAP=256 entries with FIFO eviction; check before binding lookup to
  avoid coordinator round-trip on repeats; add tests
  deferred_busy_same_conversation_same_run_id_posts_once and
  deferred_busy_same_conversation_different_run_id_posts_twice

- Fix 3: change deferred_busy_hint_from_run_state to return String; format
  concrete gate ref into BlockedApproval and BlockedAuth hints (`approve
  {ref}` / `auth deny {ref}`); fall back to static copy when gate_ref is None;
  update existing BlockedApproval and BlockedAuth tests to use scripted gate
  refs and assert concrete text; add
  deferred_busy_blocked_approval_no_gate_ref_posts_fallback_hint for None path

- Fix 4: add deferred_busy_uses_ack_active_run_id_and_binding_scope_for_state_lookup
  using RecordingTurnCoordinator to assert the forwarded GetRunStateRequest
  carries the ack's active_run_id and a non-empty tenant scope

- Fix 5: add arch-exempt: large_file annotation near top of file referencing
  decomposition issue #4818

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
henrypark133 and others added 2 commits June 12, 2026 14:28
…vel ref scrub

- Drain derives ThreadScope via ThreadScopeResolver::derive_for_terminal_event/
  derive_for_terminal_state (new resolver methods) instead of local copies of
  the owner-fallback rule, so the rewrite rule cannot drift from its owner
- Replay-ref scrubbing moves from the webui_v2 handler into
  RebornServices::get_timeline so every facade consumer gets scrubbed records,
  not just the one HTTP route; facade-level contract test added
- Failure-contract tests: non-ThreadBusy submit error and mark_message_submitted
  failure after Accepted both leave the terminal path unpoisoned
- Module doc converted to inner //! style; misleading post-submit comment fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain DeferredBusy messages once the blocking run reaches terminal state, preserving identity and retry/idempotency semantics.
Stats: 11 findings (from 14 raw, 11 after dedup) across 6 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0

bugs

  1. Medium Zero-limit deferred scan clears live deferred marker (crates/ironclaw_threads/src/in_memory.rs:304-315, confidence 75) — anchor: crates/ironclaw_threads/src/in_memory.rs:304
    list_deferred_busy_messages applies limit before deciding whether a no-cursor scan found zero deferred rows. A caller using limit: Some(0) gets an empty vector even when DeferredBusy messages exist, then removes the deferred_threads marker. Future drains take the fast path and return empty, leaving those messages permanently hidden until another defer happens.
  2. Medium Zero-limit deferred scan deletes live marker (crates/ironclaw_threads/src/filesystem_service.rs:949-962, confidence 75) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:949
    The filesystem backend has the same pre-limit/tombstone ordering bug: limit: Some(0) truncates a non-empty DeferredBusy result to empty, then the no-cursor empty check deletes deferred-busy.marker. After that, future drain calls skip the scan and the existing deferred records are no longer discoverable.
  3. Medium Drain cap can permanently strand valid tail messages (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:106-116, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:106
    The drain's total_examined cap is local to a single invocation, while invalid deferred records are intentionally left unmodified. If a thread has 64 invalid legacy DeferredBusy records before a valid one, every terminal event restarts at after_sequence None, re-examines the same invalid prefix, hits the cap again, and never reaches the valid message behind it.
    Also flagged by: tests/Medium.

performance

  1. High Deferred marker write can miss the only drain event (crates/ironclaw_threads/src/filesystem_service.rs:897-916, confidence 90) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:897
    The filesystem backend commits the message as DeferredBusy, then writes the presence marker in a separate operation. If the blocking run reaches terminal state between those two awaits, the observer calls list_deferred_busy_messages, sees no marker, returns empty, and the marker is written only after the only drain trigger has already passed. With no later terminal event or defer, that message remains DeferredBusy indefinitely under a normal timing race.
  2. Medium Deferred pagination still reads the whole thread (crates/ironclaw_threads/src/filesystem_service.rs:938-950, confidence 82) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:267
    list_deferred_busy_messages accepts limit and after_sequence, but the filesystem implementation first calls list_thread_messages, which lists the message directory and reads every message JSON before filtering and truncating. Every drain on a marked long thread therefore does O(total thread messages) file reads, and a window of invalid deferred records can repeat that scan up to eight times despite DRAIN_TOTAL_CAP.
    Also flagged by: security/Medium.

tests

  1. Medium Persisted binding refs are not asserted on submit (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:177-204, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:177
    The drain is supposed to replay the canonical source/reply refs persisted on the message, but the existing verbatim test only checks that the message becomes Submitted; it never captures the SubmitTurnRequest sent to the coordinator. A drain that re-derived different valid refs would still pass.

conventions

  1. Medium Drain bypasses the product workflow submit boundary (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:280-295, confidence 75) — anchor: crates/ironclaw_product_workflow/CLAUDE.md:7
    The new drain reconstructs a SubmitTurnRequest from thread records and calls TurnCoordinator directly from ironclaw_reborn_composition, then marks the message submitted itself. That duplicates the product-workflow inbound submit path, which owns binding resolution, turn submission, idempotency, and busy/deferred handling, so future policy changes in the product workflow path can drift from drain replays.
    Also flagged by: maintainability/Medium.
  2. Low New drain module exceeds the file-size budget (crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs:1-1, confidence 75) — anchor: .claude/rules/architecture.md:126
    This PR adds deferred_busy_drain.rs as a 2440-line new file. The architecture rule says new Rust files should aim for under 800 lines and that oversized files need a tracking issue; this module has no arch-exempt/tracking note, so the drain implementation and its large test harness are starting above the repo's reviewability budget.

local-patterns

  1. Low Deferred-busy docs describe the wrong replay path (crates/ironclaw_threads/src/contract.rs:438-440, confidence 75) — anchor: .claude/rules/review-discipline.md; crates/ironclaw_threads/src/contract.rs:438
    The new request docs say the drain resubmits messages through the normal inbound path, but the changed implementation builds a SubmitTurnRequest in DeferredBusyDrainObserver and calls TurnCoordinator directly. In this repo, doc strings are treated as contract, so this stale path description will send future readers looking for product inbound replay semantics that are not actually used.
  2. Low E2E test target is only a redirect comment (crates/ironclaw_reborn_composition/tests/deferred_busy_drain_e2e.rs:1-27, confidence 75) — anchor: crates/ironclaw_reborn_composition/tests/subagent_runtime_wiring.rs:5
    This new tests/*_e2e.rs target is documented as integration tests but contains no executable tests; even grep for #[tokio::test] lands on the doc comment. Sibling test targets in this crate contain real test functions, so normal path-based navigation or cargo test --test deferred_busy_drain_e2e leads to an empty target while the actual tests are hidden inline in src/deferred_busy_drain.rs.

maintainability

  1. Medium DeferredBusy writers can omit refs the drain requires (crates/ironclaw_threads/src/service.rs:46-53, confidence 75) — anchor: crates/ironclaw_threads/src/service.rs:46
    mark_message_deferred_busy now accepts optional canonical source/reply refs, but the new drain can only resubmit records when both refs are present. That leaves the real writer contract hidden: new callers can create fresh DeferredBusy rows that are structurally undrainable, and the failure is only discovered later as a warn-and-skip path.

// status commit and the marker write misses that single drain; the
// marker lands immediately after, so the next terminal event or defer
// recovers it.
let record = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High — Deferred marker write can miss the only drain event.

The filesystem backend commits the message as DeferredBusy, then writes the presence marker in a separate operation. If the blocking run reaches terminal state between those two awaits, the observer calls list_deferred_busy_messages, sees no marker, returns empty, and the marker is written only after the only drain trigger has already passed. With no later terminal event or defer, that message remains DeferredBusy indefinitely under a normal timing race.

Fix: Serialize the status flip, marker update, and marker-based listing with a shared per-thread lock, or otherwise make the post-defer path schedule or attempt a drain after the marker is durable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8cff5cc — both defer paths retry submit_turn once after the defer (and marker) commit: Accepted = run terminated in the gap, message takes the normal accept path; ThreadBusy = marker already durable for the eventual terminal drain. Window closed from both sides; 6 caller-level tests added.

}
let after_seq = request.after_sequence.unwrap_or(0);
let mut messages: Vec<ThreadMessageRecord> = self
.list_thread_messages(&request.scope, &request.thread_id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Deferred pagination still reads the whole thread.

list_deferred_busy_messages accepts limit and after_sequence, but the filesystem implementation first calls list_thread_messages, which lists the message directory and reads every message JSON before filtering and truncating. Every drain on a marked long thread therefore does O(total thread messages) file reads, and a window of invalid deferred records can repeat that scan up to eight times despite DRAIN_TOTAL_CAP.

Fix: Use the sequence index or add a deferred-message index so the backend reads forward from after_sequence and stops once limit matching DeferredBusy user messages are found.

Also flagged by: security/Medium

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4833 — per-thread deferred index honoring limit before reading the transcript.

.cloned()
.collect();
messages.sort_by_key(|m| m.sequence);
if let Some(limit) = request.limit {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Zero-limit deferred scan clears live deferred marker.

list_deferred_busy_messages applies limit before deciding whether a no-cursor scan found zero deferred rows. A caller using limit: Some(0) gets an empty vector even when DeferredBusy messages exist, then removes the deferred_threads marker. Future drains take the fast path and return empty, leaving those messages permanently hidden until another defer happens.

Fix: Track whether any rows matched before truncating, and tombstone only when the pre-limit match set is empty.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18bb5b3 — tombstone now keyed on the untruncated match count; zero-limit contract test added.

})
.collect();
messages.sort_by_key(|m| m.sequence);
if let Some(limit) = request.limit {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Zero-limit deferred scan deletes live marker.

The filesystem backend has the same pre-limit/tombstone ordering bug: limit: Some(0) truncates a non-empty DeferredBusy result to empty, then the no-cursor empty check deletes deferred-busy.marker. After that, future drain calls skip the scan and the existing deferred records are no longer discoverable.

Fix: Base marker deletion on the untruncated match count, not on the returned page after applying limit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18bb5b3 — same pre-limit count fix on the filesystem backend, with contract test.

let mut total_examined: usize = 0;

loop {
if total_examined >= DRAIN_TOTAL_CAP {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Drain cap can permanently strand valid tail messages.

The drain's total_examined cap is local to a single invocation, while invalid deferred records are intentionally left unmodified. If a thread has 64 invalid legacy DeferredBusy records before a valid one, every terminal event restarts at after_sequence None, re-examines the same invalid prefix, hits the cap again, and never reaches the valid message behind it.

Fix: Persist progress past skipped invalid records, mark them with a non-blocking failure state, or otherwise ensure the next invocation starts after the capped window.

Also flagged by: tests/Medium

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4831 — persisted skip/non-drainable state for permanently invalid records.

thread_scope.owner_user_id.clone(),
);

let request = SubmitTurnRequest {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Drain bypasses the product workflow submit boundary.

The new drain reconstructs a SubmitTurnRequest from thread records and calls TurnCoordinator directly from ironclaw_reborn_composition, then marks the message submitted itself. That duplicates the product-workflow inbound submit path, which owns binding resolution, turn submission, idempotency, and busy/deferred handling, so future policy changes in the product workflow path can drift from drain replays.

Fix: Move the deferred-message replay boundary into ironclaw_product_workflow or expose a workflow-owned replay method that accepts persisted canonical refs, then keep the observer as wiring.

Also flagged by: maintainability/Medium

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4831 — drain resubmission moves behind a product_workflow replay entry point; observer stays as wiring.

scope: &ThreadScope,
thread_id: &ThreadId,
message_id: ThreadMessageId,
turn_source_binding_ref: Option<String>,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — DeferredBusy writers can omit refs the drain requires.

mark_message_deferred_busy now accepts optional canonical source/reply refs, but the new drain can only resubmit records when both refs are present. That leaves the real writer contract hidden: new callers can create fresh DeferredBusy rows that are structurally undrainable, and the failure is only discovered later as a warn-and-skip path.

Fix: Replace the loose optional parameters with a request type that requires canonical routing refs for new deferrals, while keeping the ThreadMessageRecord fields optional only for legacy deserialization/backward compatibility.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4831 — request type requiring canonical refs for new deferrals lands with the replay-owner refactor.

@@ -0,0 +1,2440 @@
//! Drains [`MessageStatus::DeferredBusy`] messages when a blocking run reaches

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — New drain module exceeds the file-size budget.

This PR adds deferred_busy_drain.rs as a 2440-line new file. The architecture rule says new Rust files should aim for under 800 lines and that oversized files need a tracking issue; this module has no arch-exempt/tracking note, so the drain implementation and its large test harness are starting above the repo's reviewability budget.

Fix: Split the observer tests/support into separate modules or add an arch-exempt large_file note with the decomposition issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18bb5b3 — split into deferred_busy_drain/mod.rs (494-line observer) + tests.rs.

/// Query for inbound user messages with [`MessageStatus::DeferredBusy`] on a
/// specific thread scope, ordered ascending by sequence (oldest first).
///
/// Used by the deferred-busy drain: after the blocking run reaches a terminal

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — Deferred-busy docs describe the wrong replay path.

The new request docs say the drain resubmits messages through the normal inbound path, but the changed implementation builds a SubmitTurnRequest in DeferredBusyDrainObserver and calls TurnCoordinator directly. In this repo, doc strings are treated as contract, so this stale path description will send future readers looking for product inbound replay semantics that are not actually used.

Fix: Update the comment to say the drain uses the returned records to resubmit through the coordinator with persisted canonical binding refs, or remove the path detail from this contract type.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18bb5b3 — docs now describe coordinator replay with persisted canonical refs.

@@ -0,0 +1,27 @@
//! Integration tests for `DeferredBusyDrainObserver`.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — E2E test target is only a redirect comment.

This new tests/*_e2e.rs target is documented as integration tests but contains no executable tests; even grep for #[tokio::test] lands on the doc comment. Sibling test targets in this crate contain real test functions, so normal path-based navigation or cargo test --test deferred_busy_drain_e2e leads to an empty target while the actual tests are hidden inline in src/deferred_busy_drain.rs.

Fix: Either move an executable integration-style test into this file via a small public/test-support seam, or delete/rename the no-op target and rely on the inline unit-test module without advertising an empty e2e file.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 18bb5b3 — no-op e2e target deleted; coverage lives in the inline drain tests + runtime wiring test.

henrypark133 and others added 2 commits June 12, 2026 14:58
…f-replay assertions

- Tombstone decision in list_deferred_busy_messages now uses the untruncated
  match count in both backends; limit: Some(0) can no longer evict the
  presence marker while live DeferredBusy rows exist (contract tests added)
- contract.rs request docs no longer claim drain resubmits via the inbound
  path — it replays persisted canonical refs through the coordinator
- deferred_busy_drain split into mod.rs (494-line observer) + tests.rs;
  no-op tests/deferred_busy_drain_e2e.rs target deleted
- New Scenario L captures the drained SubmitTurnRequest and asserts the
  persisted webui-src/webui-reply refs are replayed verbatim

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs`:
- Around line 14-16: Replace all internal diagnostic uses of the warn! macro in
the deferred busy-drain observer with debug! (or a typed source logger) so
background terminal-state handling never emits warn/info that can corrupt the
REPL/TUI; specifically, locate each branch where warn! is invoked (the
observer's drain-failure handlers and the other warn! occurrences referenced)
and swap warn! -> debug! while preserving the log message text and the
subsequent return Ok(()) behavior. Ensure no other behavior changes and apply
this replacement for the listed occurrences (including the many warn! calls in
the observer branches).
- Around line 106-116: The loop resets after_sequence to None each invocation
causing starvation when >DRAIN_TOTAL_CAP invalid DeferredBusy rows block valid
ones; update DeferredBusyDrainObserver so after_sequence is advanced past
fully-skipped windows (or mark/quarantine permanently-invalid rows) instead of
always restarting at None so subsequent iterations resume after the last
examined sequence; modify the logic around after_sequence and the loop that
checks DRAIN_TOTAL_CAP to persist progress across paged scans (or delete/mark
invalid rows) and add a regression test (#[tokio::test]) that inserts
DRAIN_TOTAL_CAP + 1 invalid DeferredBusy records followed by one valid record to
reproduce and verify the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5c764b5e-13aa-4b49-914f-a9e5c5a5379a

📥 Commits

Reviewing files that changed from the base of the PR and between 7418309 and c9b956a.

📒 Files selected for processing (7)
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rs
  • crates/ironclaw_threads/src/contract.rs
  • crates/ironclaw_threads/src/filesystem_service.rs
  • crates/ironclaw_threads/src/in_memory.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs
  • crates/ironclaw_threads/tests/session_thread_contract.rs

Comment thread crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs
Comment on lines +106 to +116
let mut after_sequence: Option<u64> = None;
let mut total_examined: usize = 0;

loop {
if total_examined >= DRAIN_TOTAL_CAP {
debug!(
run_id = %run_id,
total_examined,
"DeferredBusyDrainObserver: total examined cap reached, leaving rest for next drain"
);
return Ok(());

@coderabbitai coderabbitai Bot Jun 12, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

DRAIN_TOTAL_CAP can permanently starve valid deferred messages behind a long invalid head.

after_sequence is reset to None on every invocation, so once a thread has more than 64 invalid DeferredBusy records at the front, each terminal event re-examines the same first 64 and exits at the cap. Any later valid message is never reached, so the queue can stall indefinitely. Persist progress past fully-skipped windows or quarantine permanently invalid rows, and add a regression test with DRAIN_TOTAL_CAP + 1 invalid records followed by one valid record. As per coding guidelines, "Every bug fix must include a regression test (#[test] or #[tokio::test]) that reproduces the original failure."

Also applies to: 355-363

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs` around
lines 106 - 116, The loop resets after_sequence to None each invocation causing
starvation when >DRAIN_TOTAL_CAP invalid DeferredBusy rows block valid ones;
update DeferredBusyDrainObserver so after_sequence is advanced past
fully-skipped windows (or mark/quarantine permanently-invalid rows) instead of
always restarting at None so subsequent iterations resume after the last
examined sequence; modify the logic around after_sequence and the loop that
checks DRAIN_TOTAL_CAP to persist progress across paged scans (or delete/mark
invalid rows) and add a regression test (#[tokio::test]) that inserts
DRAIN_TOTAL_CAP + 1 invalid DeferredBusy records followed by one valid record to
reproduce and verify the fix.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4831 — persisted skip/non-drainable state for permanently invalid records (regression test specified there).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@henrypark133 Acknowledged — deferring to #4831 is reasonable given scope. The starvation invariant remains: a thread with > DRAIN_TOTAL_CAP permanently invalid records at the head will silently stop making progress until #4831's persisted skip/quarantine lands. Keeping this comment open until that ships.

… timing gap

A blocking run terminating between the original ThreadBusy result and the
presence marker becoming durable could miss the only drain trigger, leaving
the deferred message waiting for unrelated thread activity. Both defer paths
(product inbound and WebUI) now retry submit_turn once after
mark_message_deferred_busy commits: Accepted means the run terminated in the
gap — mark submitted and take the normal accept path (incl. re-applying
skill activation on the WebUI side); ThreadBusy keeps the deferred behavior
with the marker already durable for the eventual terminal drain; other
errors warn and leave the message recoverable. Six caller-level tests cover
retry-busy, gap-hit, and retry-error on both paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_product_workflow/tests/reborn_services_contract.rs (1)

8202-8415: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add the required large-file growth justification for this new test block.

Lines 8202-8415 add a sizable section to a file already far beyond 3,000 lines, but this addition does not include an inline arch-exempt: large_file justification tied to this expansion (or equivalent decomposition move).

As per coding guidelines, "Maintain file size discipline: ... files > 3,000 lines should have a tracking issue for decomposition. PRs adding > 200 lines to files > 3,000 lines require an inline justification."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_product_workflow/tests/reborn_services_contract.rs` around
lines 8202 - 8415, The new test block
(webui_defer_retry_still_busy_message_stays_deferred,
webui_defer_retry_gap_hit_message_becomes_submitted,
webui_defer_retry_non_busy_error_message_stays_deferred_call_succeeds) added
~200+ lines to an already-large file needs the required large-file
justification: add an inline comment immediately above the new tests stating
arch-exempt: large_file and reference a tracking/decomposition issue (or explain
why decomposition isn’t feasible), or move these tests into a new test
module/file and update imports; ensure the marker/comment is adjacent to the
tests so reviewers can see the justification.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_product_workflow/src/inbound_turn.rs`:
- Around line 698-701: The DeferredBusy paths currently return the original
blocking info (accepted_message_ref / busy from earlier) instead of the latest
blocker from the retry; update the handlers in inbound_turn.rs (the branch that
constructs InboundTurnOutcome::DeferredBusy) and the analogous path in
reborn_services.rs so they use the ThreadBusy payload from the retry
Err(TurnError::ThreadBusy(new_busy)) rather than the stale `busy` captured
earlier; locate the place constructing InboundTurnOutcome::DeferredBusy in
submit_turn/submit_turn_retry flows and replace references to the original
blocker with the retry's blocker (use the new_busy.active_run_id /
new_busy.binding / new_busy.event_cursor as needed) so callers receive the
up-to-date blocker information.

In `@crates/ironclaw_threads/src/filesystem_service.rs`:
- Around line 893-899: The comments overstate a timing guarantee: update the
comment near submit_turn and mark_message_submitted in the filesystem_service.rs
post-defer retry block and the comment in DeferredBusyDrain
(deferred_busy_drain::mod) to state the actual invariant — the retry can close
the marker-visibility gap and the drain:<message_id> key deduplicates
observer-side replays, but there is no hard ordering that the retry always wins
before a drain starts; remove language asserting the retry "beats" the observer
or guarantees exclusive ordering and instead describe the weaker race-safe
outcome (marker visibility closure + observer-side deduplication) and how those
two pieces interact to avoid double-submission.

---

Outside diff comments:
In `@crates/ironclaw_product_workflow/tests/reborn_services_contract.rs`:
- Around line 8202-8415: The new test block
(webui_defer_retry_still_busy_message_stays_deferred,
webui_defer_retry_gap_hit_message_becomes_submitted,
webui_defer_retry_non_busy_error_message_stays_deferred_call_succeeds) added
~200+ lines to an already-large file needs the required large-file
justification: add an inline comment immediately above the new tests stating
arch-exempt: large_file and reference a tracking/decomposition issue (or explain
why decomposition isn’t feasible), or move these tests into a new test
module/file and update imports; ensure the marker/comment is adjacent to the
tests so reviewers can see the justification.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bba693ce-a43e-4f81-96bc-60e19fa19768

📥 Commits

Reviewing files that changed from the base of the PR and between c9b956a and 8cff5cc.

📒 Files selected for processing (7)
  • crates/ironclaw_product_workflow/src/inbound_turn.rs
  • crates/ironclaw_product_workflow/src/reborn_services.rs
  • crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs
  • crates/ironclaw_product_workflow/tests/product_workflow_contract.rs
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs
  • crates/ironclaw_threads/src/filesystem_service.rs

Comment thread crates/ironclaw_product_workflow/src/inbound_turn.rs Outdated
Comment thread crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs
Comment thread crates/ironclaw_threads/src/filesystem_service.rs Outdated

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically resubmit DeferredBusy messages after a gate-blocked run reaches terminal state.

Stats: 15 findings (from 15 raw, 15 after dedup) across 7 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0

security

  1. Medium Drain can submit a second run after post-defer retry (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:255-258, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:257
    The new defer paths retry submit_turn with the original idempotency key after marking the message DeferredBusy. If that retry is accepted but mark_message_submitted fails, the run is already queued under the original key while the transcript record remains DeferredBusy. The drain later resubmits the same accepted_message_ref with a different key, drain:<message_id>, so coordinator idempotency will not replay the accepted retry and can create a second run for the same user message, re-executing side effects and consuming quota after a transient thread-store failure.

bugs

  1. High Deferred message can be hidden if marker write fails (crates/ironclaw_threads/src/filesystem_service.rs:900-919, confidence 82) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:900
    The filesystem backend commits the message status to DeferredBusy before writing the deferred-busy presence marker. If the marker put fails after the message update succeeds, mark_message_deferred_busy returns an error and the post-defer retry is skipped, but the message remains DeferredBusy. Later list_deferred_busy_messages returns empty when the marker is absent, so the terminal-state drain will never see or resubmit that message.
  2. Medium Retry-busy response returns stale active-run metadata (crates/ironclaw_product_workflow/src/reborn_services.rs:1827-1833, confidence 78) — anchor: crates/ironclaw_product_workflow/src/reborn_services.rs:1827
    After the post-defer retry, this arm ignores the ThreadBusy value returned by the retry and reports the active_run_id, status, and event_cursor from the original ThreadBusy. The turn store does not memoize ThreadBusy idempotency results, so the retry can legitimately be blocked by a different run with different status/cursor. WebUI clients can then watch the wrong run or resume events from a stale cursor.
  3. Medium Product retry-busy ack can report the wrong active run (crates/ironclaw_product_workflow/src/inbound_turn.rs:698-702, confidence 74) — anchor: crates/ironclaw_product_workflow/src/inbound_turn.rs:698
    The product inbound post-defer retry also discards the retry's ThreadBusy payload and returns the original active_run_id. If the first blocking run terminated and another run acquired the thread before the retry, the ProductInboundAck points adapters at the old run even though the message is now deferred behind the newer active run.

performance

  1. Medium Deferred drain limit still scans every message (crates/ironclaw_threads/src/filesystem_service.rs:941-943, confidence 92) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:137
    list_deferred_busy_messages accepts limit: Some(8) from the terminal-event drain, but the filesystem implementation first calls list_thread_messages, which lists the whole message directory and serially reads every message JSON before filtering/truncating. Any thread with a stale deferred marker and a long transcript now makes every terminal run publication perform O(total thread messages) filesystem I/O on the terminal path, even when only one deferred message can be drained.

tests

  1. Medium Terminal-event scope derivation lacks direct tests (crates/ironclaw_reborn/src/thread_scope.rs:62-79, confidence 100) — anchor: crates/ironclaw_reborn/src/thread_scope.rs:62
    derive_for_terminal_event is new public API and encodes explicit-owner fallback plus the agentless error path. The adjacent tests only cover the older resolver methods, and scoped search found only production calls from the drain observer.
  2. Medium Terminal-state scope derivation lacks direct tests (crates/ironclaw_reborn/src/thread_scope.rs:90-105, confidence 100) — anchor: crates/ironclaw_reborn/src/thread_scope.rs:90
    derive_for_terminal_state is new public API and its owner precedence differs from the event helper by falling back to state.actor. The adjacent tests do not call it or exercise the actor fallback and agentless error paths.
  3. Medium Malformed persisted binding refs are untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:190-227, confidence 75) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:190
    The drain explicitly skips records whose persisted turn_source_binding_ref or turn_reply_target_binding_ref fails parsing, but existing drain tests cover missing refs and valid refs, not malformed Some(...) refs. A regression could stop scanning or submit with re-derived refs for corrupted persisted metadata.
  4. Low Drain total-cap boundary is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:122-167, confidence 100) — anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:123
    The drain has a DRAIN_TOTAL_CAP boundary to stop paging through pathological invalid deferred records, but adjacent tests cover one skipped record and normal cascades, not the cap boundary. This leaves the bounded-scan guarantee unprotected.

conventions

  1. Low New test module exceeds the file-size budget (crates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rs:1-1, confidence 75) — anchor: .claude/rules/architecture.md:132
    The new deferred-busy drain test module is 2054 lines, which exceeds the architecture rule that new .rs files should aim for under 800 lines. This makes the new scenario suite hard to review and maintain as a single unit.
  2. Low Large contract test file grows without justification (crates/ironclaw_product_workflow/tests/reborn_services_contract.rs:549-553, confidence 75) — anchor: .claude/rules/architecture.md:136
    This PR adds 466 lines to reborn_services_contract.rs, which is already over 3000 lines, starting with another coordinator test helper. The architecture rule says files over 3000 lines need a decomposition issue, and PRs adding more than 200 lines need inline justification.

local-patterns

  1. Low Observer logs do not match sibling tracing style (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:144-148, confidence 75) — anchor: crates/ironclaw_reborn/src/subagent/completion_observer.rs:246
    The new observer prefixes tracing messages with DeferredBusyDrainObserver: throughout the module. Sibling observers rely on the tracing target plus structured fields and use event-shaped messages, so this new prefix makes log search/filtering inconsistent and adds repeated noise without carrying new context.
  2. Nit Test helper name uses an inconsistent Reborns plural (crates/ironclaw_product_workflow/tests/reborn_services_contract.rs:554-559, confidence 75) — anchor: crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs:114
    ScriptedRebornsCoordinator introduces a new pluralized name for a coordinator test double, while the crate consistently names the facade RebornServices and sibling scripted coordinator helpers use ScriptedTurnCoordinator. The typo-like plural makes the helper harder to find by the established names.

maintainability

  1. Medium Internal drain refs leak into the UI transcript record (crates/ironclaw_threads/src/contract.rs:130-141, confidence 75) — anchor: crates/ironclaw_threads/src/contract.rs:109
    ThreadMessageRecord is documented as the UI/projection transcript snapshot, but the drain-only routing refs are now added directly to that shared record. That makes every product-facing history/timeline caller carry a hidden scrub requirement, as shown by the new scrub_internal_timeline_refs helper in the product facade, and future consumers can accidentally expose or couple to drain replay metadata.

approach

  1. Medium Legacy DeferredBusy rows are outside the drain transition (crates/ironclaw_threads/src/filesystem_service.rs:927-938, confidence 75) — anchor: crates/ironclaw_threads/src/filesystem_service.rs:927
    The PR adds new persisted canonical binding refs plus a deferred-busy marker, but the filesystem list path returns empty when the new marker is absent, and the drain later skips records whose new refs are None. The base system already persisted DeferredBusy messages before either field/marker existed, so those messages remain silently dead after upgrade even though this PR's goal is to recover deferred messages. A transition/backfill path is net safer than treating pre-change rows as impossible because it makes the persisted-contract change explicit and prevents existing user messages from being stranded.

// `mark_message_submitted`, which flips the status before any drain
// fires. If the run is still active the retry gets `ThreadBusy` again
// and the marker is already durable for the next terminal event.
let record = self

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High — Deferred message can be hidden if marker write fails.

The filesystem backend commits the message status to DeferredBusy before writing the deferred-busy presence marker. If the marker put fails after the message update succeeds, mark_message_deferred_busy returns an error and the post-defer retry is skipped, but the message remains DeferredBusy. Later list_deferred_busy_messages returns empty when the marker is absent, so the terminal-state drain will never see or resubmit that message.

Fix: Make the status update and marker durable atomically, or on marker-write failure restore the message to an accepted/retryable state or make listing fall back to a full scan when marker creation may have failed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Mitigation noted on #4817 (startup sweep is the systemic repair for marker-write failure); the post-defer retry in 8cff5cc already narrows in-session impact: a failed defer returns an error to the caller, which surfaces the submit failure instead of silently parking the message.

///
/// Call sites that handle derivation failure as a non-fatal skip must use
/// this method rather than re-implementing the owner-fallback locally.
pub fn derive_for_terminal_event(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Terminal-event scope derivation lacks direct tests.

derive_for_terminal_event is new public API and encodes explicit-owner fallback plus the agentless error path. The adjacent tests only cover the older resolver methods, and scoped search found only production calls from the drain observer.

Fix: tests::thread_scope::derive_for_terminal_event_prefers_explicit_owner_falls_back_to_event_owner_and_rejects_agentless covering explicit owner, event owner fallback, and agentless error

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — derive_for_terminal_event branch tests added (explicit owner, event-owner fallback, agentless error).

///
/// Call sites that handle derivation failure as a non-fatal skip must use
/// this method rather than re-implementing the owner-fallback locally.
pub fn derive_for_terminal_state(state: &TurnRunState) -> Result<ThreadScope, &'static str> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Terminal-state scope derivation lacks direct tests.

derive_for_terminal_state is new public API and its owner precedence differs from the event helper by falling back to state.actor. The adjacent tests do not call it or exercise the actor fallback and agentless error paths.

Fix: tests::thread_scope::derive_for_terminal_state_prefers_explicit_owner_falls_back_to_actor_and_rejects_agentless covering explicit owner, actor fallback, and agentless error

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — derive_for_terminal_state branch tests added (explicit owner, actor fallback, agentless error).

}
let after_seq = request.after_sequence.unwrap_or(0);
let mut messages: Vec<ThreadMessageRecord> = self
.list_thread_messages(&request.scope, &request.thread_id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Deferred drain limit still scans every message.

list_deferred_busy_messages accepts limit: Some(8) from the terminal-event drain, but the filesystem implementation first calls list_thread_messages, which lists the whole message directory and serially reads every message JSON before filtering/truncating. Any thread with a stale deferred marker and a long transcript now makes every terminal run publication perform O(total thread messages) filesystem I/O on the terminal path, even when only one deferred message can be drained.

Fix: Maintain a DeferredBusy sequence/status index, or page through the existing sequence index and stop once the requested limit is satisfied instead of materializing the full transcript.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked in #4833 — per-thread deferred index honoring limit before transcript reads.

event_cursor,
})
}
Err(TurnError::ThreadBusy(_)) => Ok(RebornSubmitTurnResponse::DeferredBusy {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Retry-busy response returns stale active-run metadata.

After the post-defer retry, this arm ignores the ThreadBusy value returned by the retry and reports the active_run_id, status, and event_cursor from the original ThreadBusy. The turn store does not memoize ThreadBusy idempotency results, so the retry can legitimately be blocked by a different run with different status/cursor. WebUI clients can then watch the wrong run or resume events from a stale cursor.

Fix: Bind the retry busy value and return its active_run_id, status, and event_cursor.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — WebUI response now returns the retry blocker's active_run_id, status, and event_cursor; test asserts run Y + cursor 99.

let mut total_examined: usize = 0;

loop {
if total_examined >= DRAIN_TOTAL_CAP {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — Drain total-cap boundary is untested.

The drain has a DRAIN_TOTAL_CAP boundary to stop paging through pathological invalid deferred records, but adjacent tests cover one skipped record and normal cascades, not the cap boundary. This leaves the bounded-scan guarantee unprotected.

Fix: tests::deferred_busy_drain::drain_stops_after_total_cap_when_all_records_invalid covering more than DRAIN_TOTAL_CAP invalid deferred messages and no submit attempt after the cap

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — drain_stops_after_total_cap_when_all_records_invalid (Scenario M) covers >64 invalid records, bounded list calls, no submit.


/// A scripted [`TurnCoordinator`] whose `submit_turn` pops results from a
/// queue, falling back to an `Accepted` response when the queue is empty.
/// Used by post-defer retry tests that need to script two sequential

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — Large contract test file grows without justification.

This PR adds 466 lines to reborn_services_contract.rs, which is already over 3000 lines, starting with another coordinator test helper. The architecture rule says files over 3000 lines need a decomposition issue, and PRs adding more than 200 lines need inline justification.

Fix: Move the new post-defer retry coverage into a focused test file/module or add an inline large-file justification with a tracking plan.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Header note added in 0c3fd56; retry/defer coverage moves with #4831.

@@ -0,0 +1,2054 @@
use std::sync::Arc;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — New test module exceeds the file-size budget.

The new deferred-busy drain test module is 2054 lines, which exceeds the architecture rule that new .rs files should aim for under 800 lines. This makes the new scenario suite hard to review and maintain as a single unit.

Fix: Split the drain harness and scenario groups into smaller test modules, or add an explicit decomposition plan if this must stay monolithic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Decomposition note added in 0c3fd56; the suite moves with the #4831 replay-owner refactor.

{
Ok(messages) => messages,
Err(error) => {
warn!(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Low — Observer logs do not match sibling tracing style.

The new observer prefixes tracing messages with DeferredBusyDrainObserver: throughout the module. Sibling observers rely on the tracing target plus structured fields and use event-shaped messages, so this new prefix makes log search/filtering inconsistent and adds repeated noise without carrying new context.

Fix: Remove the DeferredBusyDrainObserver: prefix from the new debug!/warn! messages, or add structured context such as observer = "deferred_busy_drain" if an explicit discriminator is needed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — prefix dropped, observer structured field added.

/// Used by post-defer retry tests that need to script two sequential
/// `submit_turn` outcomes for a single inbound call.
#[derive(Clone, Default)]
struct ScriptedRebornsCoordinator {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Nit — Test helper name uses an inconsistent Reborns plural.

ScriptedRebornsCoordinator introduces a new pluralized name for a coordinator test double, while the crate consistently names the facade RebornServices and sibling scripted coordinator helpers use ScriptedTurnCoordinator. The typo-like plural makes the helper harder to find by the established names.

Fix: Rename the helper and its impl/use sites to ScriptedTurnCoordinator or ScriptedRebornServicesCoordinator.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0c3fd56 — renamed to ScriptedTurnCoordinator.

…ng, coverage

- DeferredBusy outcomes after the post-defer retry now report the RETRY's
  blocking run (active_run_id, and status/event_cursor on WebUI) instead of
  the original blocker, so clients watch the run actually holding the thread
- Drain observer logging converted to debug! with observer field per the
  REPL/TUI logging rule; name prefix dropped from messages
- Retry-guarantee comments reworded to the actual invariant (marker-visibility
  closure + observer-side dedup, no hard retry/drain ordering)
- New coverage: DRAIN_TOTAL_CAP boundary, malformed persisted refs skip,
  derive_for_terminal_event/state branch tests, retry-runs-twice assertions
- ScriptedRebornsCoordinator renamed to ScriptedTurnCoordinator; file-size
  decomposition notes pointing at #4831

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_reborn/src/thread_scope.rs (1)

62-66: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Give the new public derivation helpers a typed error.

These methods were just added to the public API, but they return &'static str. That makes downstream handling and future evolution contract-by-text. A small ThreadScopeDerivationError enum would keep the surface stable without changing the call pattern. As per coding guidelines, "Prefer strong types over strings (enums, newtypes)".

Also applies to: 90-93

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn/src/thread_scope.rs` around lines 62 - 66, Replace the
&'static str error returns from the new public derivation helpers (e.g.,
derive_for_terminal_event and the similar method around lines 90–93) with a
typed error enum (e.g., ThreadScopeDerivationError) and update their signatures
to return Result<ThreadScope, ThreadScopeDerivationError>; implement variants
for the current cases (for example AgentlessScope) and adjust all early returns
(currently returning Err("agentless turn scope — no ThreadScope")) to return the
appropriate enum variant; update any callers/tests to pattern-match the enum (or
use ? where appropriate) so external code can handle errors via type rather than
raw strings.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_product_workflow/tests/reborn_services_contract.rs`:
- Around line 8282-8330: The test
webui_defer_retry_still_busy_reports_retry_run_id_not_original currently only
asserts active_run_id and event_cursor; extend the pattern matcher on outcome
(RebornSubmitTurnResponse::DeferredBusy) to also check the reported status
equals the retry's status (the queued status pushed for run_y, e.g.
TurnStatus::Queued) so the DeferredBusy payload is validated for active_run_id,
status, and event_cursor together (use the existing id and EventCursor(99)
checks and add a status == TurnStatus::Queued condition referencing
run_y/coordinator setup).

---

Outside diff comments:
In `@crates/ironclaw_reborn/src/thread_scope.rs`:
- Around line 62-66: Replace the &'static str error returns from the new public
derivation helpers (e.g., derive_for_terminal_event and the similar method
around lines 90–93) with a typed error enum (e.g., ThreadScopeDerivationError)
and update their signatures to return Result<ThreadScope,
ThreadScopeDerivationError>; implement variants for the current cases (for
example AgentlessScope) and adjust all early returns (currently returning
Err("agentless turn scope — no ThreadScope")) to return the appropriate enum
variant; update any callers/tests to pattern-match the enum (or use ? where
appropriate) so external code can handle errors via type rather than raw
strings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e59805a1-8d5c-425e-849f-6c2509a9d011

📥 Commits

Reviewing files that changed from the base of the PR and between 8cff5cc and 0c3fd56.

📒 Files selected for processing (8)
  • crates/ironclaw_product_workflow/src/inbound_turn.rs
  • crates/ironclaw_product_workflow/src/reborn_services.rs
  • crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn/src/thread_scope.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rs
  • crates/ironclaw_threads/src/filesystem_service.rs

Comment thread crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
If the post-defer retry is accepted but mark_message_submitted fails, a run
is already queued under the original key while the record stays DeferredBusy.
The drain previously resubmitted with its own drain:<message_id> key, which
the coordinator would not dedupe — producing a second run for the same user
message. Deferred records now persist the original submit key
(turn_idempotency_key, internal field scrubbed at the facade like the binding
refs) and the drain replays it verbatim; drain:<message_id> remains only as
the fallback for records persisted before the field existed. Contract,
defer-path, and drain capture tests cover persisted-key replay and the
legacy fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/ironclaw_product_workflow/src/reborn_services.rs (1)

2748-2769: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail loud on deferred-mark write failures.

Line 2766 only verifies MessageStatus::DeferredBusy. If mark_message_deferred_busy(...) returns after flipping status but before persisting turn_source_binding_ref, turn_reply_target_binding_ref, or turn_idempotency_key, this path still returns success and leaves an undrainable record that the new drain will skip as legacy. Either verify the full persisted message contract here, or propagate the error instead of reconciling on status alone.

As per coding guidelines, "Fail loud: flag silent-failure patterns — warn-and-continue that poisons state. Errors propagate with ? into thiserror types with context." and "Test through the caller: when a helper gates a side effect, require a test driving the real call site, not only the helper."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_product_workflow/src/reborn_services.rs` around lines 2748 -
2769, The current handling of mark_message_deferred_busy(...) treats any replay
with MessageStatus::DeferredBusy as success, which can hide partial-write
failures of turn_source_binding_ref, turn_reply_target_binding_ref, or
turn_idempotency_key; change this so we either (A) verify the full persisted
contract after mark_message_deferred_busy by loading the persisted
message/replay and asserting the binding fields match the sent
turn_source_binding_ref, turn_reply_target_binding_ref and
client_action_id/turn_idempotency_key (fail with Err if any mismatch/missing),
or (B) stop reconciling on status alone and propagate the original error instead
of returning Ok(()) — update the code around mark_message_deferred_busy,
reconcile_terminal_duplicate, and the predicate (currently |replay|
replay.status == MessageStatus::DeferredBusy) to include checks for those
binding fields so incomplete writes do not silently succeed.

Source: Coding guidelines

♻️ Duplicate comments (1)
crates/ironclaw_reborn_composition/src/runtime.rs (1)

6669-6758: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

This regression still bypasses the real deferred-busy caller.

The test reconstructs the deferred state with direct accept_inbound_message / submit_turn / mark_message_deferred_busy calls, so it can pass even if the shipped caller stops marking busy messages correctly. For this bug, the guard needs to drive the actual runtime/product entrypoint that produces DeferredBusy, then assert the drain resubmits it after the blocking run terminates. As per coding guidelines, "Test through the caller: when a helper gates a side effect, require a test driving the real call site (handler/factory/manager), not only the helper."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/src/runtime.rs` around lines 6669 - 6758,
The test is rebuilding a deferred-busy state by calling
accept_inbound_message/submit_turn/mark_message_deferred_busy directly, which
bypasses the real caller that produces DeferredBusy; change the test to drive
the actual runtime entrypoint (the handler/factory/manager that normally handles
inbound turns and, on ThreadBusy, invokes mark_message_deferred_busy—see
inbound_turn.rs behavior) instead of calling mark_message_deferred_busy
directly, by submitting the second message through the same public entrypoint
that previously produced DeferredBusy via turn_coordinator/thread_service and
arranging a blocking/ongoing run (the first Accepted/Submit path using
turn_coordinator::submit_turn that returns ThreadBusy) so the drain observer
fires on A's terminal event and you can assert the drain resubmits B; ensure you
reference and use accept_inbound_message, turn_coordinator::submit_turn, and the
real inbound turn handler path rather than directly invoking
mark_message_deferred_busy in the test.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs`:
- Around line 1875-1907: The test writes both binding refs but only asserts the
source; add an assertion that result[0].turn_reply_target_binding_ref.as_deref()
equals Some("reply:binding-fs") (mirroring the existing turn_source_binding_ref
check) so the persistence contract verifies reply binding is stored; update the
same test that calls mark_message_deferred_busy(...) and
list_deferred_busy_messages(...) and use the existing result and expected_key
variables to locate where to insert this new assertion.

---

Outside diff comments:
In `@crates/ironclaw_product_workflow/src/reborn_services.rs`:
- Around line 2748-2769: The current handling of mark_message_deferred_busy(...)
treats any replay with MessageStatus::DeferredBusy as success, which can hide
partial-write failures of turn_source_binding_ref,
turn_reply_target_binding_ref, or turn_idempotency_key; change this so we either
(A) verify the full persisted contract after mark_message_deferred_busy by
loading the persisted message/replay and asserting the binding fields match the
sent turn_source_binding_ref, turn_reply_target_binding_ref and
client_action_id/turn_idempotency_key (fail with Err if any mismatch/missing),
or (B) stop reconciling on status alone and propagate the original error instead
of returning Ok(()) — update the code around mark_message_deferred_busy,
reconcile_terminal_duplicate, and the predicate (currently |replay|
replay.status == MessageStatus::DeferredBusy) to include checks for those
binding fields so incomplete writes do not silently succeed.

---

Duplicate comments:
In `@crates/ironclaw_reborn_composition/src/runtime.rs`:
- Around line 6669-6758: The test is rebuilding a deferred-busy state by calling
accept_inbound_message/submit_turn/mark_message_deferred_busy directly, which
bypasses the real caller that produces DeferredBusy; change the test to drive
the actual runtime entrypoint (the handler/factory/manager that normally handles
inbound turns and, on ThreadBusy, invokes mark_message_deferred_busy—see
inbound_turn.rs behavior) instead of calling mark_message_deferred_busy
directly, by submitting the second message through the same public entrypoint
that previously produced DeferredBusy via turn_coordinator/thread_service and
arranging a blocking/ongoing run (the first Accepted/Submit path using
turn_coordinator::submit_turn that returns ThreadBusy) so the drain observer
fires on A's terminal event and you can assert the drain resubmits B; ensure you
reference and use accept_inbound_message, turn_coordinator::submit_turn, and the
real inbound turn handler path rather than directly invoking
mark_message_deferred_busy in the test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c8bca171-5e32-4d6e-b0d3-8f533b7475ab

📥 Commits

Reviewing files that changed from the base of the PR and between 0c3fd56 and d40728e.

📒 Files selected for processing (19)
  • crates/ironclaw_loop_support/src/compaction_task.rs
  • crates/ironclaw_loop_support/src/subagent_spawn_port/tests.rs
  • crates/ironclaw_loop_support/tests/thread_loop_support_contract.rs
  • crates/ironclaw_product_workflow/src/inbound_turn.rs
  • crates/ironclaw_product_workflow/src/reborn_services.rs
  • crates/ironclaw_product_workflow/tests/reborn_services_contract.rs
  • crates/ironclaw_reborn/src/loop_exit_applier/tests/mod.rs
  • crates/ironclaw_reborn/src/subagent/completion_observer.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs
  • crates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rs
  • crates/ironclaw_threads/src/contract.rs
  • crates/ironclaw_threads/src/filesystem_service.rs
  • crates/ironclaw_threads/src/in_memory.rs
  • crates/ironclaw_threads/src/service.rs
  • crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs
  • crates/ironclaw_threads/tests/session_thread_contract.rs
  • crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs

Comment thread crates/ironclaw_threads/tests/filesystem_session_thread_contract.rs

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Automatically drain DeferredBusy messages after a blocking run reaches terminal state so user messages are resubmitted instead of silently stranded.

Stats: 12 findings (from 14 raw, 12 after dedup/filter) across 7 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 4.

performance

  1. High Marker tombstone can hide a concurrent deferred message (crates/ironclaw_threads/src/filesystem_service.rs:970-976, confidence 90) - anchor: crates/ironclaw_threads/src/filesystem_service.rs:970
    The filesystem backend deletes the thread-level deferred marker after an empty scan without proving the marker is still the same one it read before the scan. If a concurrent inbound path commits a new DeferredBusy message and refreshes the marker between the scan and this delete, the delete removes the fresh marker; future list_deferred_busy_messages calls then take the marker-absent fast path and never scan the now-deferred message.
  2. Medium Drain limit still scans every thread message (crates/ironclaw_threads/src/filesystem_service.rs:945-955, confidence 82) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:146
    The drain requests limit=8, but the filesystem implementation first calls list_thread_messages, which lists and reads every message JSON for the thread, then filters, sorts, and truncates. On long threads with DeferredBusy present, every terminal event pays O(total thread messages) filesystem reads to drain at most one message; draining k queued messages becomes O(k*n) reads.

bugs

  1. Medium Ownerless terminal runs drain the wrong thread scope (crates/ironclaw_reborn/src/thread_scope.rs:94-98, confidence 75) - anchor: crates/ironclaw_reborn/src/thread_scope.rs:94
    For TurnScope::new_with_owner(..., None), thread_owner is explicitly Ownerless, but derive_for_terminal_state treats it like ActorFallback and falls back to state.actor. The drain then looks under owner_user_id = actor instead of the ownerless thread scope where DeferredBusy messages were stored, so ownerless threads never drain after terminal state.

conventions

  1. Medium Internal drain refs serialize on transcript records (crates/ironclaw_threads/src/contract.rs:135-150, confidence 100) - anchor: crates/ironclaw_threads/CLAUDE.md:7
    ThreadMessageRecord is documented as a UI/projection transcript snapshot, but the new internal drain replay refs use skip_serializing_if rather than the existing internal-metadata pattern at lines 124-127, so any direct transcript serialization outside RebornServices::get_timeline can expose routing refs and idempotency keys as ordinary transcript fields. This violates the threads guardrail to never expose raw runtime/private backend metadata as ordinary transcript content.
  2. Medium Drain replay logic lives in composition (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:144-147, confidence 75) (body only) - anchor: crates/ironclaw_product_workflow/CLAUDE.md:7
    The new composition module owns deferred-message selection, binding-ref replay, idempotency replay, and direct turn resubmission, but product_workflow is the documented owner for message staging, idempotency, and busy/deferred handling. The PR body explains why the original product envelope is not available, so treat this as design discussion unless paired with the concrete serialization/ownership follow-up.

tests

  1. Medium Timeline scrub lacks idempotency-key coverage (crates/ironclaw_product_workflow/src/reborn_services.rs:3699-3705, confidence 100) - anchor: crates/ironclaw_product_workflow/src/reborn_services.rs:3704
    scrub_internal_timeline_refs clears turn_idempotency_key as internal drain metadata, but the existing facade and wire tests only cover source/reply refs and seed turn_idempotency_key as None. A regression that leaked persisted idempotency keys through RebornServices::get_timeline would not fail.
  2. Medium Retry-accepted mark-submitted failure is untested (crates/ironclaw_product_workflow/src/inbound_turn.rs:680-693, confidence 75) - anchor: crates/ironclaw_product_workflow/src/inbound_turn.rs:689
    The post-defer retry Accepted branch propagates mark_message_submitted failures as ProductWorkflowError::Transient, but adjacent tests cover retry Accepted, retry ThreadBusy, and retry non-busy submit errors without exercising this mark-submitted error path.
  3. Medium Malformed idempotency-key skip path is untested (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:278-289, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:279
    The drain validates message.turn_idempotency_key and skips the record on parse failure, but tests only cover valid persisted keys and None fallback. A malformed stored key at the head of the deferred list could block later valid messages if this skip path regresses.
  4. Medium Drain pagination lacks valid-second-window test (crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:409-418, confidence 75) - anchor: crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rs:418
    The drain advances after a full window of invalid DeferredBusy records via after_sequence, but tests cover invalid+valid in one window and all-invalid-to-cap, not a valid record in the next window. A regression in after_sequence/window_last_sequence could strand messages after the first invalid page.

maintainability

  1. Low Post-defer retry flow is duplicated across submit paths (crates/ironclaw_product_workflow/src/reborn_services.rs:1781-1848, confidence 75) (body only) - anchor: crates/ironclaw_product_workflow/src/reborn_services.rs:1789 duplicates crates/ironclaw_product_workflow/src/inbound_turn.rs:676
    The WebUI submit path duplicates the same protocol added in inbound_turn.rs: clone SubmitTurnRequest, mark DeferredBusy with canonical refs, retry once, mark submitted on accepted retry, return DeferredBusy on ThreadBusy, and warn on other retry errors. This retry is now part of the deferred-busy contract, so keeping two inline copies makes future changes to the race closure or idempotency behavior easy to drift.

local-patterns

  1. Low Generic observer failures are reported as subagent completion (crates/ironclaw_reborn/src/runtime.rs:419-423, confidence 75) (body only) - anchor: crates/ironclaw_reborn/src/runtime.rs:419-423
    additional_required_observers can now contain non-subagent observers such as the deferred-busy drain, but subscription failures are still wrapped in DefaultPlannedRuntimeBuildError::SubagentCompletion, whose Display text says subagent completion wiring failed. Startup diagnostics point at the wrong subsystem when a generic lifecycle observer fails to subscribe.
  2. Nit Field doc promises warn-level drain behavior (crates/ironclaw_threads/src/contract.rs:133-134, confidence 50) (body only) - anchor: crates/ironclaw_threads/src/contract.rs:133-134; .claude/rules/review-discipline.md
    The new turn_source_binding_ref doc says legacy None records are warned and skipped, but the drain module documents and implements non-poisoning failures at debug!. That stale wording can send readers looking for warning-level telemetry that will not exist.

// below the cursor — evicting the marker there would hide them from
// all future drains. Likewise, a limit-truncated non-empty match set
// must not delete the marker even if the returned slice is empty.
if pre_limit_empty && request.after_sequence.is_none() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

High - Marker tombstone can hide a concurrent deferred message.

The filesystem backend deletes the thread-level deferred marker after an empty scan without proving the marker is still the same one it read before the scan. If a concurrent inbound path commits a new DeferredBusy message and refreshes the marker between the scan and this delete, the delete removes the fresh marker; future list_deferred_busy_messages calls then take the marker-absent fast path and never scan the now-deferred message.

Fix: Make marker deletion conditional on the marker version observed before the scan, or stop deleting this optimization marker and tolerate stale-marker extra scans.

Also flagged by: approach/Low

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

.scope
.explicit_owner_user_id()
.cloned()
.or_else(|| state.actor.as_ref().map(|a| a.user_id.clone()));

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Ownerless terminal runs drain the wrong thread scope.

For TurnScope::new_with_owner(..., None), thread_owner is explicitly Ownerless, but derive_for_terminal_state treats it like ActorFallback and falls back to state.actor. The drain then looks under owner_user_id = actor instead of the ownerless thread scope where DeferredBusy messages were stored, so ownerless threads never drain after terminal state.

Fix: Only fall back to the actor when the turn scope is not explicit; preserve None for explicit ownerless scopes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

}
let after_seq = request.after_sequence.unwrap_or(0);
let mut messages: Vec<ThreadMessageRecord> = self
.list_thread_messages(&request.scope, &request.thread_id)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drain limit still scans every thread message.

The drain requests limit=8, but the filesystem implementation first calls list_thread_messages, which lists and reads every message JSON for the thread, then filters, sorts, and truncates. On long threads with DeferredBusy present, every terminal event pays O(total thread messages) filesystem reads to drain at most one message; draining k queued messages becomes O(k*n) reads.

Fix: Maintain an indexed DeferredBusy queue or use the existing sequence index/ranged reads so list_deferred_busy_messages stops after the requested limit instead of materializing the whole thread.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

///
/// `None` on records written before this field was added (legacy). The drain
/// warns and skips those entries.
#[serde(default, skip_serializing_if = "Option::is_none")]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Internal drain refs serialize on transcript records.

ThreadMessageRecord is documented as a UI/projection transcript snapshot, but the new internal drain replay refs use skip_serializing_if rather than the existing internal-metadata pattern at lines 124-127, so any direct transcript serialization outside RebornServices::get_timeline can expose routing refs and idempotency keys as ordinary transcript fields. This violates the threads guardrail to never expose raw runtime/private backend metadata as ordinary transcript content.

Fix: Keep these fields off the public serialized ThreadMessageRecord shape and persist them through the private stored-record wrapper, like tool_result_provider_call.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

) -> ironclaw_threads::ThreadMessageRecord {
record.turn_source_binding_ref = None;
record.turn_reply_target_binding_ref = None;
record.turn_idempotency_key = None;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Timeline scrub lacks idempotency-key coverage.

scrub_internal_timeline_refs clears turn_idempotency_key as internal drain metadata, but the existing facade and wire tests only cover source/reply refs and seed turn_idempotency_key as None. A regression that leaked persisted idempotency keys through RebornServices::get_timeline would not fail.

Fix: tests::reborn_services_contract::get_timeline_scrubs_internal_idempotency_key_at_facade_boundary covering a DeferredBusy message marked with Some(turn_idempotency_key) and asserting the timeline record returns None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

total_examined,
"full window skipped, advancing past sequence {window_last_sequence}"
);
after_sequence = Some(window_last_sequence);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Drain pagination lacks valid-second-window test.

The drain advances after a full window of invalid DeferredBusy records via after_sequence, but tests cover invalid+valid in one window and all-invalid-to-cap, not a valid record in the next window. A regression in after_sequence/window_last_sequence could strand messages after the first invalid page.

Fix: tests::deferred_busy_drain::drain_advances_past_full_invalid_window_and_submits_next covering first DRAIN_LIST_LIMIT records invalid and the next window containing a valid DeferredBusy message

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

// field existed (turn_idempotency_key = None) fall back to the
// legacy `drain:<message_id>` prefix.
let idempotency_key = match message.turn_idempotency_key.as_deref() {
Some(raw) => match IdempotencyKey::new(raw) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Malformed idempotency-key skip path is untested.

The drain validates message.turn_idempotency_key and skips the record on parse failure, but tests only cover valid persisted keys and None fallback. A malformed stored key at the head of the deferred list could block later valid messages if this skip path regresses.

Fix: tests::deferred_busy_drain::drain_skips_malformed_persisted_idempotency_key_and_submits_next covering malformed Some(turn_idempotency_key) followed by a valid deferred message

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

run_id.to_string(),
)
.await
.map_err(|e| ProductWorkflowError::Transient {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium - Retry-accepted mark-submitted failure is untested.

The post-defer retry Accepted branch propagates mark_message_submitted failures as ProductWorkflowError::Transient, but adjacent tests cover retry Accepted, retry ThreadBusy, and retry non-busy submit errors without exercising this mark-submitted error path.

Fix: tests::inbound_turn_contract::defer_retry_accept_mark_submitted_failure_returns_transient covering retry Accepted followed by SessionThreadService::mark_message_submitted error

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Approach withdrawn — after review we are dropping the defer-and-drain mechanism in favor of explicit gate-open feedback (no background resubmission). This PR is being closed; the branch stays for reference. Replacement CX lands in a follow-up PR.

@henrypark133

Copy link
Copy Markdown
Collaborator Author

Closing — design decision: we are dropping the defer-and-drain mechanism entirely. Messages arriving while a gate-blocking run holds the thread will get explicit gate-open feedback ("an approval gate is open; resolve it before continuing") instead of being parked and auto-resubmitted. Rationale: no background resubmission machinery in the agent loop — the user stays the retry actor; the entire replay-identity/idempotency/marker class of complexity disappears.

The branch (feat/deferred-busy-drain, through d40728e) stays pushed for reference: it contains the fully review-hardened drain (5 review passes, 64 comments resolved) should this ever be revisited.

Replacement CX: follow-up PR rendering the gate-open notice across channels, with no parking semantics.

henrypark133 added a commit that referenced this pull request Jun 15, 2026
* feat(product): explicit gate-open feedback for busy threads, no parking

Design decision (supersedes the closed defer-and-drain PR #4812): a message
arriving while another run holds the thread is recorded with the honest
terminal status RejectedBusy and the user gets an explicit notice — gate-aware
("an approval gate is open on this thread — resolve it before continuing,
then resend") when the blocking run is BlockedApproval/BlockedAuth, generic
otherwise. No background resubmission: the user is the retry actor.

- threads: MessageStatus::RejectedBusy + mark_message_rejected_busy (both
  backends); DeferredBusy kept as a legacy deserialization label, no longer
  written; RejectedBusy -> Submitted allowed so resends work
- product_workflow: ThreadBusy branches mark RejectedBusy; response variant
  renamed RejectedBusy with status-derived notice field
- webui: rejected_busy ack renders the notice as a system message in chat;
  wire-shape test asserts tag + notice
- slack: copy already honest from #4811; stale #4812 revisit comment removed
- e2e: runtime-level test proves ThreadBusy -> RejectedBusy with notice,
  NO resubmission on the blocking run's terminal event, and a fresh submit
  succeeds afterward

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): make RejectedBusy terminal across replay, ack, compaction, UI

Reviewers found RejectedBusy still inheriting auto-resubmit/defer semantics,
contradicting the no-parking contract. Fixes:

- inbound_turn: from_replay_parts returns a terminal AlreadyRejected handoff
  for RejectedBusy (re-rejects, never resubmits); to_ack now emits a settled
  ProductInboundAck::RejectedBusy so transport retries get Duplicate instead
  of resubmitting. Legacy DeferredBusy rows keep the resubmit path.
- reborn_services: replayed RejectedBusy returns RebornSubmitTurnResponse::
  RejectedBusy again (idempotent re-rejection) instead of building a fresh
  submission; status-to-notice mapping locked by BlockedApproval/BlockedAuth/
  generic tests.
- compaction: RejectedBusy (and frozen legacy DeferredBusy) map to
  SkipEphemeral(StableNonModelVisible), not DeferUntilStable, so busy threads
  can still compact instead of deferring forever.
- webui useChat: always clear processing on rejected_busy, mark the optimistic
  message failed, collision-free system-message id; added hook tests.
- tests: runtime no-resubmission assertion anchored on message identity;
  mark_message_rejected_busy negative coverage; webui handler test reuses
  StubServices via a queued response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(threads): retire mark_message_deferred_busy writer

DeferredBusy is now a read-only legacy label — production writes RejectedBusy.
Remove the live writer from the SessionThreadService trait, both backends, the
Arc forwarder, and all test fakes. Legacy DeferredBusy read/replay coverage is
preserved via a doc-hidden inject_legacy_deferred_busy_for_test back-door on the
in-memory backend (never called from production). The DeferredBusy enum variant
and all read/replay/compaction handling are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): RejectedBusy follow-ups — Slack hint, honest replay, fail-loud, gating

- slack_delivery: SlackFinalReplyDeliveryObserver now recognizes
  ProductInboundAck::RejectedBusy { active_run_id: Some(_) } and posts the
  gate-aware busy hint (was DeferredBusy-only, so Slack rejections settled
  silently); None active_run_id posts nothing. Tests added.
- reborn_services: RejectedBusy response run metadata (active_run_id, status,
  event_cursor) is now Option — fresh ThreadBusy returns Some(real values),
  idempotent replay returns None instead of fabricating a fake Running run at
  cursor 0. Wire-shape + replay tests updated.
- inbound_turn: RejectedBusy replay fails loud on a malformed stored
  turn_run_id instead of silently dropping it.
- compaction: RejectedBusy + frozen legacy DeferredBusy map to
  SkipEphemeral(StableNonModelVisible), not DeferUntilStable, so busy threads
  compact instead of deferring forever. Integration tests added.
- threads: inject_legacy_deferred_busy_for_test gated behind a test-support
  cargo feature (absent from production builds); filesystem contract coverage
  for mark_message_rejected_busy (happy + invalid transitions).
- webui useChat: always clear processing on rejected_busy, mark the optimistic
  message failed, collision-free system-message id; hook tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test/docs(review): RejectedBusy coverage, durable timeline rejection, contract docs

- inbound_turn: regression test for fail-loud malformed RejectedBusy turn_run_id
- product_workflow_contract: RejectedBusy(None) settles + transport retry = Duplicate
- webui wire test: assert status + event_cursor present on fresh RejectedBusy path
- thread contracts (both backends): RejectedBusy -> Submitted resend transition
- webui useChat: persisted rejected_busy/deferred_busy rows now render failed on
  history reload with durable resend copy (was a normal-looking sent message);
  history-messages tests added
- slack_delivery: shared busy-hint path renamed deferred_busy_* -> busy_hint_*
  (fns, call sites, logs, docs); DeferredBusy kept only in the legacy arm
- runtime.rs: inline arch justification above the large RejectedBusy e2e test
- docs/reborn/contracts: product-adapters.md + conversation-binding.md document
  RejectedBusy as a durable terminal outcome; DeferredBusy marked legacy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): openai-compat build break, Slack duplicate hint, conversations doc

- openai-compat-beta: ProductInboundAck::RejectedBusy added to the four
  non-exhaustive match sites (ack_helpers, chat_workflow, responses_workflow x2),
  mapped to the same retryable 429 as DeferredBusy — fixes E0004 that broke any
  build enabling openai-compat-beta. RejectedBusy->429 tests added.
- slack_delivery: busy-hint run-id extraction now unwraps
  Duplicate { prior } recursively, so a transport retry arriving as
  Duplicate { prior: RejectedBusy { Some(run) } } still posts the busy-thread
  hint when the first was lost; the per-(conversation, run_id) throttle
  suppresses genuine repeat posts. Tests added.
- ironclaw_conversations/CLAUDE.md: the idempotency guardrail now distinguishes
  transient submit failures (retry, rotate key) from thread-busy admission
  (terminal RejectedBusy, no retry-until-submitted, user resends).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): RejectedBusy terminal at storage, compaction safety, ledger no-fake-run

- threads: RejectedBusy removed from ensure_user_accepted in both backends —
  a stored RejectedBusy row can no longer transition to Submitted; resend is a
  fresh message. Prior-pass RejectedBusy->Submitted tests inverted to assert the
  transition is now terminal (InvalidMessageTransition). DeferredBusy admission
  kept (legacy replay still resubmits).
- compaction: only RejectedBusy (terminal) is SkipEphemeral; DeferredBusy moved
  back to DeferUntilStable since legacy rows can still reach Submitted — prevents
  a summary silently omitting a message that later becomes model-visible.
- workflow ledger: RejectedBusy { active_run_id: None } maps to
  ActionDispatchKind::NoOp instead of minting a fresh TurnRunId; still settles
  durably, no fabricated run id.
- inbound_turn test: the misnamed legacy-DeferredBusy test now actually injects a
  legacy DeferredBusy row and asserts resubmission, distinct from the RejectedBusy
  re-rejection test.
- openai-compat: added cancel-path RejectedBusy->429 handler test.
- slack_delivery: comments corrected to describe the recursive Duplicate{prior}
  extraction (no longer claim Duplicate{DeferredBusy} returns None).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): non-retryable 429 for terminal RejectedBusy + busy rename + coverage

Address open PR review findings on the busy-thread rejection work:

- openai-compat: split the busy ack arm so terminal RejectedBusy maps to
  429 retryable=false (client must issue a new request), while legacy
  DeferredBusy keeps retryable=true. Covers chat create, responses create,
  and responses cancel paths; regression unit tests on the retryable flag.
- slack_delivery: rename SLACK_DEFERRED_BUSY_* constants to SLACK_BUSY_*
  (path now serves RejectedBusy + legacy DeferredBusy); refresh the stale
  "silently dropped (pending gate)" comment to cover generic RejectedBusy.
- compaction_task: correct the StableNonModelVisible doc comment — only
  terminal RejectedBusy is skipped; legacy DeferredBusy is DeferUntilStable
  (it can still transition to Submitted).
- tests: add filesystem legacy DeferredBusy on-disk round-trip coverage and
  a reborn_services RejectedBusy mark-failure reconcile-via-replay test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): update root busy test to terminal RejectedBusy contract

The root integration test product_workflow_retries_after_filesystem_deferred_busy_release
still asserted the legacy DeferredBusy auto-resubmit contract (busy ->
DeferredBusy -> retry resubmits, submission_count 1->2). The live product
workflow now emits terminal RejectedBusy for busy user messages; the PR
updated crate-level tests but missed this root-level one, failing the
"Reborn root tests" CI job.

Rewrite + rename to product_workflow_rejects_busy_and_does_not_resubmit_on_filesystem_replay,
mirroring crates/ironclaw_product_workflow inbound_turn_contract's
rejected_busy_replay_is_re_rejected_not_resubmitted: first ack is
RejectedBusy (submission_count == 1); a same-event replay settles via the
idempotency ledger and returns Duplicate { prior: RejectedBusy } with no
resubmission (submission_count stays 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): terminal RejectedBusy cut point no longer blocks compaction

Review finding (High): the compaction terminal cut-point validation rejected
every SkipEphemeral disposition with InvalidCutPoint, but RejectedBusy now
classifies as SkipEphemeral(StableNonModelVisible). So a compaction range whose
drop_through_seq landed on a RejectedBusy message hard-failed — contradicting
this PR's goal that terminal RejectedBusy must never block compaction.

Allow a stable-non-model-visible terminal (RejectedBusy) as a legal cut point:
it is excluded from the compacted output like the in-range SkipEphemeral case
and compaction proceeds. Non-User Include and RejectInvalid still error.
Regression test: compaction_port_accepts_terminal_cut_point_that_is_rejected_busy.

Also add legacy_deferred_busy_mark_failure_reconciles_via_replay covering the
reconcile branch's legacy DeferredBusy replay path (RejectedBusy was already
covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): narrow terminal cut-point accept to StableNonModelVisible

Review follow-up: the terminal cut-point arm accepted SkipEphemeral(_) with a
wildcard, which would also admit CapabilityDisplayPreview as a valid terminal.
Only StableNonModelVisible (terminal RejectedBusy) should qualify. Match the
explicit variant so other ephemeral skip reasons fall through to
InvalidCutPoint and fail loud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): reconcile only RejectedBusy as terminal + coverage/naming follow-ups

Address the latest review round (post origin/main merge):

- reborn_services: the mark-failure reconcile path treated legacy DeferredBusy
  as a terminal already-settled state. DeferredBusy is non-terminal (a later
  replay treats it like Accepted and can resubmit), so claiming terminal over
  it violated the no-resubmit guarantee. Drop DeferredBusy from the reconcile
  predicate — only RejectedBusy is terminal; a DeferredBusy row now surfaces
  the mark failure (503 retryable) instead of a false-terminal RejectedBusy.
  Flipped the legacy test to assert the surfaced error.
- compaction: add regression test that a terminal CapabilityDisplayPreview cut
  point returns InvalidCutPoint (only StableNonModelVisible is a legal terminal).
- fakes: FakeProductAdapter now records RejectedBusy in accepted_envelopes
  (durable like Accepted/DeferredBusy) so fake-based tests don't undercount.
- slack_delivery: arch-exempt comment updated from deferred-busy to busy-thread
  / RejectedBusy terminology.
- reborn_services_contract: retext a busy-submit test that asserts RejectedBusy
  but still labeled the path "deferred".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(test): correct scripted-helper comments — DeferredBusy is non-terminal

Follow-up to the reconcile fix: the DeferredBusyMarkFails scripted helper and
its replay branch still documented the old behavior (DeferredBusy "settles"
reconciliation). reconcile_terminal_duplicate now accepts only RejectedBusy as
terminal, so a DeferredBusy replay surfaces the mark error (Unavailable/503)
instead of a false-terminal RejectedBusy. Comment-only; logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(test): align sibling probe-count comments with 3-call reconcile flow

Follow-up: the replay_call_count field doc and rejected_busy_mark_fails() doc
still described the old 2-call probe sequence. Both scripted mark-fail helpers
return None on the first two idempotency probes and Some(..) on the third
(reconcile) probe (count <= 2 guard). Comment-only; logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): reject empty cut-point range instead of summarizing nothing

Review finding (Medium): making a terminal RejectedBusy a legal cut point opened
an edge — a range whose only message is that rejection (or any all-skip-ephemeral
span) produced an empty validated_messages, then still ran inference on an empty
prompt and persisted a meaningless summary artifact.

Guard after the deferred-reason check: if validated_messages is empty, return
InvalidCutPoint before build_input — nothing model-visible to summarize. The
deferred-reason early-return stays first so legitimate deferrals are unaffected.
Regression test: a range whose only message is a terminal RejectedBusy returns
InvalidCutPoint and never calls inference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test/docs(review): RejectedBusy command ack, ack_helpers, 429 spec, banner

Address review test/doc gaps on the busy-rejection work:

- product_command_workflow_contract: cover the command-dispatch path where
  command_service returns RejectedBusy -> UnsupportedActionKind -> terminal
  Rejected ack (previously only user-message RejectedBusy was tested).
- ack_helpers: unit-test that internal_refs_from_ack rejects RejectedBusy with
  the internal error (no internal refs bound for a terminal busy ack).
- docs/reborn/contracts/openai-compatible-api.md: document the busy 429 split —
  terminal RejectedBusy is non-retryable (client must issue a new request),
  legacy DeferredBusy stays retryable.
- reborn_services_contract: add the missing opening separator on the Legacy
  DeferredBusy test section banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): trim summary span to last visible message at a busy cut point

Review finding (Medium): when the compaction cut point is a terminal RejectedBusy,
the summary span ended at drop_through_seq, covering that non-visible message. The
thread backends' context builder skips any ReplaceRangeWhenSelected summary whose
span covers a non-model-context-visible message (summary_covers_hidden_content), so
the summary was persisted but never applied — a dead artifact.

Trim end_sequence to the last model-visible (Include'd) message's sequence so the
span excludes trailing non-visible terminals; the summary then applies. Folds the
empty-range guard into the same `validated_messages.last()` match (None => empty
range => InvalidCutPoint) — no production unwrap/expect. Regression test asserts a
[visible@1, RejectedBusy@2] range compacted through seq 2 yields a summary spanning
end_sequence=1, not 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: pin command RejectedBusy error to ProductAdapterError::Internal

Review nit: the command-RejectedBusy test used a bare expect_err (any error).
Pin the concrete public variant: ProductAdapterError::Internal — which is what
ProductWorkflowError::UnsupportedActionKind maps to at the adapter boundary. The
kind string ("unsupported action kind: ...") is wrapped in RedactedString and
not exposed via Display, so Internal is the tightest assertable pin from the
public return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(threads): summary may span permanently-terminal non-visible messages

Review finding (Medium, egGm): summary_covers_hidden_content blocked a
ReplaceRangeWhenSelected summary whose span covered ANY non-model-context-visible
message. Compaction legitimately spans non-visible rows it skipped from the
summary content (e.g. an interior terminal RejectedBusy, or a capability preview),
so those summaries were silently dropped — the compaction-layer trailing trim
couldn't fix an interior hole.

Block the summary only when the span covers a non-visible message that can still
RESURFACE as model-visible (Draft / Interrupted / Superseded / DeferredBusy).
Permanently-terminal non-visible rows (RejectedBusy, CapabilityDisplayPreview
kind) never resurface, so spanning them is safe — the summary content already
excludes them and they are never shown in context. Identical change in both
in_memory and filesystem backends via a shared can_resurface_as_model_visible
helper; Redacted/Deleted keep blocking. Also corrects the pre-existing
capability-preview span behavior (two tests updated). Regression tests on both
backends: interior RejectedBusy summary is applied; interior Draft is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
elliotBraem added a commit to NEARBuilders/ironclaw that referenced this pull request Jun 17, 2026
* fix(runtime-context): key comms preferences by run owner + cover JoinError gaps (#4895)

Addresses post-merge review findings on #4836.

- Bug (Medium): the communication-context provider keyed outbound
  delivery preferences by the *actor* instead of the run *owner*. Product
  inbound and trusted-trigger runs can carry an explicit thread owner
  (subject/creator) distinct from the actor; the stored preference belongs
  to the owner. Resolve the caller's user_id via
  `scope.explicit_owner_user_id()` with actor fallback — matching
  `TurnScope::to_resource_scope` — so shared/channel inbound and trigger
  runs render the owner's delivery target, not the actor's. Adds two
  regression tests asserting the lookup is keyed by owner vs actor through
  a caller-capturing facade.

- Tests (Medium): cover `CommunicationContextFetch::resolve`'s JoinError
  branches that were previously unexercised — actorless failure degrades
  to `None`, actor-present failure degrades to `Some(Unknown)`.

- Docs (Low): the product-context-factory plan still specified the
  rejected 256-byte `RunOriginAdapter` bound; update to the as-built
  512-byte cap (mirroring `AdapterKind`) so follow-up work does not
  reintroduce the narrowing.

Not addressed (verified out of scope): the `model_safe_label` "injection"
finding is a false positive — label sources are admin/system-set and the
sanitizer strips structural characters (existing hostile-input tests pass);
the shared-route surface assertion finding is already covered by
`shared_user_message_records_channel_surface_type`; the string-based
trigger-helper finding is owned by issue #4851's plan; the
RunOriginAdapter byte-mirror is already mitigated by a named const + docs
+ tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: surface missing-credential auth gate before the approval gate (#4840)

* fix(host_runtime): surface AuthRequired before approval gate on missing credentials (Fix B)

Extracts capability_credential_requirements() as the single source of truth
for credential requirements derived from the capability manifest descriptor.
Both the new credential pre-flight check and the existing dispatch-time
obligation check call this function — no second computation added.

Adds credential_preflight_check() on DefaultHostRuntime and calls it in
invoke_capability() and spawn_capability() BEFORE apply_persistent_approval_policy(),
so AuthRequired is returned without persisting an approval request when a
required credential is absent.

Wires the optional secret store from HostRuntimeServices.build_host_runtime()
into DefaultHostRuntime via with_credential_preflight_store(). The pre-flight
is skipped in minimal/test graphs that don't supply a secret store; the
dispatch-time obligation check remains the enforcement backstop regardless.

Tests (host_runtime_services_contract):
- invoke_capability_missing_credential_returns_auth_before_approval
- invoke_capability_present_credential_proceeds_to_approval
- invoke_capability_no_credential_requirement_proceeds_normally
- credential_requirements_preflight_and_dispatch_agree_on_same_handles

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(host_runtime): correct capability_credential_requirements docstring and strengthen test

The docstring claimed "both the pre-flight check and the dispatch-time
obligation check call this function" — that was false. The obligation
handler in BuiltinObligationHandler derives required handles by iterating
descriptor.runtime_credentials directly; it does not call this function.
Correct the docstring to accurately describe the agreement at source-data
level (both iterate the same required-true entries) and explain why gate-ID
divergence between pre-flight and backstop is moot in practice (pre-flight
fires first when a secret store is wired).

Rename `credential_requirements_preflight_and_dispatch_agree_on_same_handles`
to `credential_requirements_extraction_matches_descriptor_required_credentials`
with a scope note clarifying what the test actually verifies (canonical fn
vs descriptor, not vs obligation handler), add an assertion that
credential_requirements is empty for secret_handle source type, and reference
the caller-level test that covers the gate-ordering guarantee.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(host-runtime): skip credential pre-flight on store errors per review

On a transient SecretStore Err, credential_preflight_check previously
returned AuthRequired (treating backend failure as credential absent),
burning a user auth interaction. Now it returns None on Err so the
dispatch-time obligation check remains the sole enforcement backstop.

Also applied:
- FIX 2: add trust-class-agnostic comment at both pre-flight call sites
- FIX 3: rename field secret_store → credential_preflight_store to match
  the with_credential_preflight_store builder
- FIX 4: take registry.snapshot() once in invoke_capability and
  spawn_capability and pass it into credential_preflight_check, removing
  the redundant internal snapshot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(host-runtime): add credential pre-flight edge-case tests per review

- FIX 5: change section separator to triple-dash style matching
  memory_prompt_context.rs
- FIX 6: four new tests:
  a. spawn_capability_missing_credential_returns_auth_before_approval —
     spawn path mirrors invoke_capability pre-flight behavior
  b. invoke_capability_no_credential_requirement_with_wired_store_proceeds_normally —
     wired store + zero required credentials hits is_empty() branch, not
     no-store early exit
  c. invoke_capability_secret_store_error_skips_preflight — erroring
     store stub confirms pre-flight skips on Err (FIX 1) and flow
     reaches the approval gate
  d. credential_requirements_extraction_returns_empty_for_all_optional_credentials —
     descriptor with only required=false credentials yields empty vecs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(host-runtime): address #4840 review — product-auth preflight skip, scope validation, test wiring

- capability_credential_requirements no longer treats ProductAuthAccount injection
  slots as presence-checkable secrets, fixing a false-positive AuthRequired preflight
  for capabilities with already-connected product-auth accounts.
- Validate context/resource-scope consistency before the credential preflight queries
  the secret store, closing a forged-scope presence-probe window (invoke + spawn).
- Wire the preflight store in contract fixtures and seed credentials on the request's
  own ResourceScope; add product-auth and scope-validation regression tests.
- silent-ok annotation on the store-error skip; docs name both invoke and spawn;
  moved preflight tests to host_runtime_credential_preflight_contract.rs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(host-runtime): #4840 review round 2 — test fidelity + tighten public surface

- store-error regression now drives the manifest-backed credential backstop via
  ApprovalThenGrantAuthorizer (was masked by a test-authorizer-injected obligation).
- add spawn_capability present-credential happy-path test (proceeds to ApprovalRequired).
- make the credential-preflight-store setter test-only/crate-private; tests wire it
  through HostRuntimeServices::build_host_runtime.
- stop publishing capability_credential_requirements from the crate root; keep it
  crate-private with equivalent coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(host-runtime): exercise the real dispatch-time obligation backstop on store error (#4840)

The store-error regression now grants the required secret so dispatch authorization
passes and the resumed call reaches BuiltinObligationHandler::preflight_secret_injection,
where the AlwaysErrorSecretStore metadata() probe errors and the handler fails closed
(secret_obligation_failed). Previously the grant omitted the secret, so the block came
from grant-matching authorization — not the dispatch-time credential backstop the PR
contract relies on (obligations.rs preflight_secret_injection fails closed on store error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(host-runtime): single owner for the secret-presence rule (#4840)

Extract obligations::secret_present as the one definition of 'is this required
secret present in scope', shared by the credential pre-flight (ordering) and the
dispatch-time obligation backstop (enforcement). Removes the duplicated metadata()
presence rule across production.rs and obligations.rs so the two paths cannot drift.
Each caller still owns its store-error policy (pre-flight fails open and skips; the
backstop fails closed). The happy-path double-read remains (documented) — addresses
the split-ownership half of the review; collapsing the read is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(host-runtime): make store-error backstop test airtight + cover required product-auth (#4840)

Addresses PR #4840 review round 3:
- The store-error regression now uses a metadata()-call-counting error store and
  asserts the resume drove at least one further probe after a counter reset — proving
  the resume reaches BuiltinObligationHandler::preflight_secret_injection (authorization
  passed) and fails closed there, not at a premature authorization denial. Both paths map
  to RuntimeFailureKind::Authorization, so the probe count is the distinguishing signal.
  Removes the now-unused AlwaysErrorSecretStore and the stale test doc.
- Corrects the comment wording: the secret-injection obligation comes from grant
  evaluation against the manifest, not from ApprovalThenGrantAuthorizer.
- Adds credential_requirements_extraction_excludes_required_product_auth_account unit
  test: a required product_auth_account credential is excluded from required_secrets
  (no false-positive pre-flight AuthRequired) but still surfaces in credential_requirements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(host-runtime): preserve secret-store failure cause when failing closed (#4840)

The dispatch-time obligation backstop dropped SecretStoreError with map_err(|_|),
collapsing outages into an opaque secret-obligation failure with no server-side
trail. Bind the error and log it at debug! (SecretStoreError Display carries no raw
secret material) before returning the sanitized secret_obligation_failed(); the
caller still receives the opaque error. Mirror the same cause-logging in the
fail-open pre-flight skip arm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(agent-loop): surface resume-origin capability failures instead of dying as scope_mismatch (#4899)

A 2nd+ Slack approval (or auth) resume of a capability could terminally
fail with scope_mismatch ("capability input ref is not scoped to this
loop run"). On a resume dispatch returning a transient Backend error,
handle_capability_error cleared the pending resume slot, then the
RecoveryOutcome::Retry path re-dispatched via
capability_invocation_from_candidate(call, None) — dropping the resume
context. The non-resume path resolved the original-run input_ref against
the resuming run, failed ensure_ref_scoped_to_run, and killed the run as
HostUnavailable.

Intercept Retry for approval-resume AND auth-resume origin failures:
surface the real backend error to the model as a tool result and
continue the loop (so the user can re-approve / re-auth) instead of
re-dispatching. Kills the scope_mismatch (S1) and avoids
double-executing a side effect whose lease is one-shot after a Backend
error (S2).

Adds regression tests for both resume origins.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui): keep code block overflow local (#4791)

* fix(webui): keep code block overflow local

* fix(webui): keep code block typography readable after merge

* fix(auth-resume): preserve input replay across gate boundaries (#4910)

* fix auth resume input replay

* fix auth resume approval token carryover

* fix(reborn): normalize bare workspace tool paths (#4846)

* fix(reborn): normalize bare workspace tool paths

* Preserve scoped path URL validation for workspace aliases

* Normalize empty workspace alias segments

---------

Co-authored-by: Robert Yan <mstr.raphael@gmail.com>

* [codex] Represent Slack as a product-adapter extension (#4778)

* feat(reborn): declare Slack channel as extension manifest

* fix(reborn): address extension review feedback

* fix(reborn): address Slack product-adapter extension review feedback

- Reserve "slack" as host-bundled extension id (prevent filesystem shadowing)
- Add builtin_first_party_trust_policy regression test for Slack admin entry
- Activate manifest-backed channel packages from WebUI (suppress only wasm_channel)
- Preserve legacy Slack connect controls for pre-install deployments
- Project only ProductSurfaceKind::ExternalChannel to channel kind
- Parse each manifest once via ExtensionManifestRecord
- Rename product_adapter.host_beta section to stable product_adapter.inbound
- Add caller-level tests: list_extension_registry, extension_info, ChannelsTab render

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(runtime-context): enable connected-channel classification via surface_kinds

#4778 lands the ProductAdapter surface projection, so the lifecycle summary
now carries surface_kinds. Flip CHANNEL_CLASSIFICATION_AVAILABLE to true and
make extension_is_channel_surface a real predicate (ExternalChannel), so
connected channel names render in the model runtime context instead of unknown.

Convert the two stubbed tests to positive cases: empty list -> Known([]),
mixed list -> only active channel-surface extensions reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(runtime-context): cargo fmt + drop unreachable classification branch

Remove the dead 'if !CHANNEL_CLASSIFICATION_AVAILABLE' arm inside the
Some(Ok(response)) match: lifecycle_fut only issues the ExtensionList call
when classification is enabled, so a present response always means it is on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): repair Slack extension asset + locale checks after channel refactor

- assets.rs smoke test: assert showLegacySlackConnectActions (the refactor's
  built-in Slack status path) instead of the removed slackBuiltinStatus helper
- add extensions.kind.channel to all 10 non-en locales (en gained the key with
  the new channel surface kind; locale-parity test requires all locales match)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(webui-v2): cover ExtensionCard channel overflow + localize registry heading

Review findings (PR #4778 review 4493764666):
- F2: add extension-card.test.mjs proving kind=channel/wasm_channel surface
  Setup (setup_required/failed) and Reconfigure (active/ready) overflow actions
  on the real component; channels-tab.test stubbed ExtensionCard so this was
  uncovered.
- F4: render the 'Available channels' registry heading via t(channels.availableChannels)
  instead of a hardcoded literal; add the key to all 11 locales. Update the
  channels-tab test to locate the registry section by the RegistryCard component
  (heading is now an interpolated value, not a template literal); drop the now
  unused renderedValueAfter helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(runtime-context): drop permanent classification flag, document surface_kinds cache

Review findings (PR #4778 review 4493764666):
- F5: remove CHANNEL_CLASSIFICATION_AVAILABLE (permanently true after the stub
  flip) and run the lifecycle ExtensionList fetch unconditionally when a
  lifecycle facade is wired.
- F3: document AvailableExtensionPackage.surface_kinds as an intentional
  single-parse cache (re-deriving in summary() would re-run the manifest
  projection, undoing the parse-once optimization).

F1 (per-turn ExtensionList cost) accepted as-is: the fetch is spawned off the
critical path under a 500 ms budget with abort-on-drop; caching deferred to a
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): suppress Activate for channel kinds during pairing

Review finding (PR #4778 review 4494623523, finding 1): primaryExtensionAction
only suppressed the primary Activate button for legacy wasm_channel, so a
manifest-backed kind=channel Slack card fell through to 'activate' in
pairing_required/pairing states where the dedicated pairing section already
owns the flow. Suppress the primary action for channel-surface kinds in those
states (via isChannelExtensionKind); installed channels still return 'activate'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): add channels.slack key + regression tests for surface-kind projection

Review findings (PR #4778 review 06:28):
- Add missing channels.slack i18n key to all 11 locales (legacy Slack row
  rendered the raw key because the i18n helper returns the key on miss; the
  || "Slack" fallback never fired).
- Add filesystem-path test: a /system manifest with
  product_adapter.inbound.surface_kind = external_channel projects to
  ExternalChannel surface (previously only the bundled catalog path was covered).
- Add extension_kind regression test: non-channel summaries keep their runtime
  wire kind (wasm_tool, mcp_server) while channel surfaces map to "channel".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): gate Slack catalog entry behind slack-v2-host-beta; test wasm_channel registry

Review findings (PR #4778 review 07:01):
- Gate the Slack first-party catalog entry, its only-Slack symbols (slack_package,
  slack_assets, SLACK_MANIFEST, slack_manifest_digest), the factory trust-policy
  Slack AdminEntry, and Slack-asserting tests behind slack-v2-host-beta. Without
  the feature the Slack route/runtime/WebUI mounts don't exist, so the catalog
  must not advertise an unrunnable Slack extension. Clean clippy + tests in both
  feature-on and feature-off configs.
- Add useExtensions hook test proving an uninstalled kind=wasm_channel registry
  entry lands in channelRegistry, not toolRegistry (isChannelExtensionKind covers
  both channel and wasm_channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): consolidate Slack trust policy tests (#4778)

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(agent-loop): gated final-answer nudge (reborn empty/canned turn endings) (#4837)

* feat(agent-loop): gated final-answer nudge to avoid empty/canned turn endings

When the reborn loop would otherwise end a turn with no real assistant
answer — an empty/trailed-off reply, the model-call budget exhausted, or
NoProgressDetected (which emits a canned "I stopped repeating the same
step" reply) — issue ONE extra tool-free model call asking the model to
synthesize a closing answer from the work it already did. This is the
reborn equivalent of the legacy loop's on_tool_intent_nudge /
force-text-recovery.

Gated by the (previously unimplemented) SteeringPolicy
`allow_driver_specific_nudges` flag, which defaults to false — so
production behavior is unchanged. Capped at one nudge per run
(`LoopExecutionState.final_answer_nudges_used`) so it can't issue
unbounded extra model calls. Wired into all three exit modes
(assistant_reply empty/trailed, budget IterationLimit, NoProgressDetected);
falls back to existing behavior when disabled, capped, or the model still
declines to answer.

Mechanism note: the tool-free call uses an EMPTY capability_view
(visible_capability_ids: []), not surface_version=None — the reborn model
gateway attaches tools from the capability port regardless of
surface_version, so only an empty view yields a true text-only request.

Evidence (PinchBench, Qwen3.5-122B, claude-haiku judge, controlled A/B on
the same merged tree): nudge OFF 0.682 vs nudge ON 0.768 (+0.086), closing
reborn to v2 parity (0.793). It specifically rescues tasks that do the
work but trip the no-progress detector (stock, events, spreadsheet,
polymarket), turning the canned give-up into a real answer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* review: route nudge through admission + accounting, drop dead branch, add tests

Addresses review feedback on the final-answer nudge:

- Remove the empty/trailed-reply nudge branch in AssistantReplyStage. As the
  reviewer noted, DefaultReplyAdmissionStrategy rejects empty/artifact replies
  before they reach AssistantReplyStage, so that branch was dead for the default
  family. Empty replies are rejected → the loop continues → NoProgressDetected,
  which is where the nudge still fires. assistant_reply.rs is back to main.
- The nudged reply now goes through the SAME admission policy as a normal reply
  (ctx.planner.reply_admission().admit_reply) instead of a bare !is_empty()
  check — so blank text and provider-transcript artifacts can't be finalized.
- Preserve canonical assistant-reply token accounting: record the nudge turn's
  output tokens (provider usage, else the same estimate AssistantReplyStage
  uses) into recent_output_token_counts so the diminishing-returns window isn't
  fed stale data.
- Add caller-level tests with the gate enabled at the boundaries the nudge
  affects: no-progress (synthesizes via one tool-free model call), budget
  iteration-limit (completes instead of failing closed), gate-disabled (no model
  call, canned fallback), and the one-shot cap (no second call).

All 302 ironclaw_agent_loop lib tests pass.

Note on the remaining structural point (model call still issued from the exit
boundary via the host primitive rather than ModelStage): see PR discussion —
the exit-boundary stages don't own Prompt/Model, so a full "typed stage outcome"
move means restructuring the terminal-exit flow to re-enter the loop for one
tool-free turn. Happy to do that if preferred over this factoring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* review: address CodeRabbit findings on final-answer nudge

- Move FINAL_ANSWER_NUDGE prompt out of Rust source into
  prompts/final_answer_nudge.md, loaded via include_str! (repo prompt-template
  invariant).
- Fix stale tool-free comment: clarify that the empty capability view on the
  model request (not the surface_version/capability_view None assignments) is
  what suppresses provider tools.
- Make MockHost::with_driver_nudges_enabled flip the steering flag in-place so
  it composes with other context-level builders regardless of order; drop the
  now-unused test_run_context_with_driver_nudges fixture.
- Add legacy-checkpoint decode regression test asserting a payload missing
  final_answer_nudges_used decodes to 0 (#[serde(default)] contract).
- Apply rustfmt to the new stage tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Pranav Raja <pranav.raja@near.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>

* Expose outbound delivery targets to Reborn model (#4779)

* feat(reborn): declare Slack channel as extension manifest

* fix(reborn): address extension review feedback

* fix(reborn): address Slack product-adapter extension review feedback

- Reserve "slack" as host-bundled extension id (prevent filesystem shadowing)
- Add builtin_first_party_trust_policy regression test for Slack admin entry
- Activate manifest-backed channel packages from WebUI (suppress only wasm_channel)
- Preserve legacy Slack connect controls for pre-install deployments
- Project only ProductSurfaceKind::ExternalChannel to channel kind
- Parse each manifest once via ExtensionManifestRecord
- Rename product_adapter.host_beta section to stable product_adapter.inbound
- Add caller-level tests: list_extension_registry, extension_info, ChannelsTab render

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(runtime-context): enable connected-channel classification via surface_kinds

#4778 lands the ProductAdapter surface projection, so the lifecycle summary
now carries surface_kinds. Flip CHANNEL_CLASSIFICATION_AVAILABLE to true and
make extension_is_channel_surface a real predicate (ExternalChannel), so
connected channel names render in the model runtime context instead of unknown.

Convert the two stubbed tests to positive cases: empty list -> Known([]),
mixed list -> only active channel-surface extensions reported.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(runtime-context): cargo fmt + drop unreachable classification branch

Remove the dead 'if !CHANNEL_CLASSIFICATION_AVAILABLE' arm inside the
Some(Ok(response)) match: lifecycle_fut only issues the ExtensionList call
when classification is enabled, so a present response always means it is on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): repair Slack extension asset + locale checks after channel refactor

- assets.rs smoke test: assert showLegacySlackConnectActions (the refactor's
  built-in Slack status path) instead of the removed slackBuiltinStatus helper
- add extensions.kind.channel to all 10 non-en locales (en gained the key with
  the new channel surface kind; locale-parity test requires all locales match)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(webui-v2): cover ExtensionCard channel overflow + localize registry heading

Review findings (PR #4778 review 4493764666):
- F2: add extension-card.test.mjs proving kind=channel/wasm_channel surface
  Setup (setup_required/failed) and Reconfigure (active/ready) overflow actions
  on the real component; channels-tab.test stubbed ExtensionCard so this was
  uncovered.
- F4: render the 'Available channels' registry heading via t(channels.availableChannels)
  instead of a hardcoded literal; add the key to all 11 locales. Update the
  channels-tab test to locate the registry section by the RegistryCard component
  (heading is now an interpolated value, not a template literal); drop the now
  unused renderedValueAfter helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(runtime-context): drop permanent classification flag, document surface_kinds cache

Review findings (PR #4778 review 4493764666):
- F5: remove CHANNEL_CLASSIFICATION_AVAILABLE (permanently true after the stub
  flip) and run the lifecycle ExtensionList fetch unconditionally when a
  lifecycle facade is wired.
- F3: document AvailableExtensionPackage.surface_kinds as an intentional
  single-parse cache (re-deriving in summary() would re-run the manifest
  projection, undoing the parse-once optimization).

F1 (per-turn ExtensionList cost) accepted as-is: the fetch is spawned off the
critical path under a 500 ms budget with abort-on-drop; caching deferred to a
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): suppress Activate for channel kinds during pairing

Review finding (PR #4778 review 4494623523, finding 1): primaryExtensionAction
only suppressed the primary Activate button for legacy wasm_channel, so a
manifest-backed kind=channel Slack card fell through to 'activate' in
pairing_required/pairing states where the dedicated pairing section already
owns the flow. Suppress the primary action for channel-surface kinds in those
states (via isChannelExtensionKind); installed channels still return 'activate'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(webui-v2): add channels.slack key + regression tests for surface-kind projection

Review findings (PR #4778 review 06:28):
- Add missing channels.slack i18n key to all 11 locales (legacy Slack row
  rendered the raw key because the i18n helper returns the key on miss; the
  || "Slack" fallback never fired).
- Add filesystem-path test: a /system manifest with
  product_adapter.inbound.surface_kind = external_channel projects to
  ExternalChannel surface (previously only the bundled catalog path was covered).
- Add extension_kind regression test: non-channel summaries keep their runtime
  wire kind (wasm_tool, mcp_server) while channel surfaces map to "channel".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): gate Slack catalog entry behind slack-v2-host-beta; test wasm_channel registry

Review findings (PR #4778 review 07:01):
- Gate the Slack first-party catalog entry, its only-Slack symbols (slack_package,
  slack_assets, SLACK_MANIFEST, slack_manifest_digest), the factory trust-policy
  Slack AdminEntry, and Slack-asserting tests behind slack-v2-host-beta. Without
  the feature the Slack route/runtime/WebUI mounts don't exist, so the catalog
  must not advertise an unrunnable Slack extension. Clean clippy + tests in both
  feature-on and feature-off configs.
- Add useExtensions hook test proving an uninstalled kind=wasm_channel registry
  entry lands in channelRegistry, not toolRegistry (isChannelExtensionKind covers
  both channel and wasm_channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): consolidate Slack trust policy tests (#4778)

* feat(reborn): expose outbound delivery targets to model

---------

Co-authored-by: Henry Park <henrypark133@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): unify extension registry flow (#4900)

* fix(reborn): unify extension registry flow

* fix(reborn): address extension registry review comments

* test(reborn): cover merged extension registry flow

* fix(reborn): clean google oauth configure copy

* fix(reborn): align notion oauth setup copy

* fix(reborn): clean token setup copy

* fix(reborn): prefer GitHub extension for repository data (#4894)

* fix(reborn): prefer github extension for repository data

* refactor(reborn): centralize github http routing hint

* fix(reborn): filter PRs from GitHub issue listings (#4888)

* fix(reborn): filter pull requests from github issues

* fix(reborn): preserve github issue pagination

* fix(reborn): use issue-only github search pagination

* fix(reborn): update github wasm trace fixture

* fix: auto-generate BETTER_AUTH_SECRET in bos-dev.sh and track styles.css in git

- bos-dev.sh now generates BETTER_AUTH_SECRET via openssl when .env is created or has an empty value
- Track ui/src/styles.css in git (add ! exception in .gitignore)
- Prevents auth plugin crash and UI build failure on fresh clones

* feat(reborn): polish the Automations panel UI (#4919)

* fix(automations-ui): readable summary cards and NEXT RUN value

Reflow the summary strip to at most three cards per row so the detail
text no longer wraps one word per line, and let StatCard accept a
valueClassName override so the NEXT RUN date renders at a smaller size
instead of truncating to "Jun…". Default StatCard sizing is unchanged.

* fix(automations-ui): surface delivery save errors and gate Slack hint

The delivery-defaults panel swallowed save/clear failures and showed no
feedback; it now renders an inline error from the mutation and flashes the
"Saved" confirmation on Clear as well as Save. The "reply approve <code> in
Slack" footnote is hidden unless an external Slack-style target exists.

* fix(automations-ui): label sub-hourly cron schedules

Minute- and hour-level cadences such as "* * * * *", "*/15 * * * *", and
"0 * * * *" rendered as "Custom schedule" because they have no single clock
time. They now read as "Every minute", "Every 15 minutes", and "Hourly at
:00".

* fix(automations-ui): space the run-row action button icons

The "Open run" and "Logs" buttons in the recent-runs list rendered the
icon flush against the label because the non-primary Button variants don't
add a gap between children. Add the same icon margin the rest of the app
uses for icon+label buttons.

* test(automations): lock the panel UI fixes into the served bundle

Add static-asset assertions driving the composed router so each Automations
panel UX fix — sub-hourly cron labels, summary card reflow + smaller NEXT RUN
value, run-row icon spacing, and delivery save-error/Slack-hint gating — is
guarded against a regression that drops it from the shipped SPA source.

* fix(i18n): add automations.delivery.saveFailed to every locale pack

The new key was added only to en.js, which breaks the i18n consistency test
that requires all locale packs to share the English key set. Add it to the
ten other packs (English placeholder, matching the existing untranslated
automations strings there).

* Explicit gate-open feedback for busy threads (no parking) (#4838)

* feat(product): explicit gate-open feedback for busy threads, no parking

Design decision (supersedes the closed defer-and-drain PR #4812): a message
arriving while another run holds the thread is recorded with the honest
terminal status RejectedBusy and the user gets an explicit notice — gate-aware
("an approval gate is open on this thread — resolve it before continuing,
then resend") when the blocking run is BlockedApproval/BlockedAuth, generic
otherwise. No background resubmission: the user is the retry actor.

- threads: MessageStatus::RejectedBusy + mark_message_rejected_busy (both
  backends); DeferredBusy kept as a legacy deserialization label, no longer
  written; RejectedBusy -> Submitted allowed so resends work
- product_workflow: ThreadBusy branches mark RejectedBusy; response variant
  renamed RejectedBusy with status-derived notice field
- webui: rejected_busy ack renders the notice as a system message in chat;
  wire-shape test asserts tag + notice
- slack: copy already honest from #4811; stale #4812 revisit comment removed
- e2e: runtime-level test proves ThreadBusy -> RejectedBusy with notice,
  NO resubmission on the blocking run's terminal event, and a fresh submit
  succeeds afterward

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): make RejectedBusy terminal across replay, ack, compaction, UI

Reviewers found RejectedBusy still inheriting auto-resubmit/defer semantics,
contradicting the no-parking contract. Fixes:

- inbound_turn: from_replay_parts returns a terminal AlreadyRejected handoff
  for RejectedBusy (re-rejects, never resubmits); to_ack now emits a settled
  ProductInboundAck::RejectedBusy so transport retries get Duplicate instead
  of resubmitting. Legacy DeferredBusy rows keep the resubmit path.
- reborn_services: replayed RejectedBusy returns RebornSubmitTurnResponse::
  RejectedBusy again (idempotent re-rejection) instead of building a fresh
  submission; status-to-notice mapping locked by BlockedApproval/BlockedAuth/
  generic tests.
- compaction: RejectedBusy (and frozen legacy DeferredBusy) map to
  SkipEphemeral(StableNonModelVisible), not DeferUntilStable, so busy threads
  can still compact instead of deferring forever.
- webui useChat: always clear processing on rejected_busy, mark the optimistic
  message failed, collision-free system-message id; added hook tests.
- tests: runtime no-resubmission assertion anchored on message identity;
  mark_message_rejected_busy negative coverage; webui handler test reuses
  StubServices via a queued response.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(threads): retire mark_message_deferred_busy writer

DeferredBusy is now a read-only legacy label — production writes RejectedBusy.
Remove the live writer from the SessionThreadService trait, both backends, the
Arc forwarder, and all test fakes. Legacy DeferredBusy read/replay coverage is
preserved via a doc-hidden inject_legacy_deferred_busy_for_test back-door on the
in-memory backend (never called from production). The DeferredBusy enum variant
and all read/replay/compaction handling are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): RejectedBusy follow-ups — Slack hint, honest replay, fail-loud, gating

- slack_delivery: SlackFinalReplyDeliveryObserver now recognizes
  ProductInboundAck::RejectedBusy { active_run_id: Some(_) } and posts the
  gate-aware busy hint (was DeferredBusy-only, so Slack rejections settled
  silently); None active_run_id posts nothing. Tests added.
- reborn_services: RejectedBusy response run metadata (active_run_id, status,
  event_cursor) is now Option — fresh ThreadBusy returns Some(real values),
  idempotent replay returns None instead of fabricating a fake Running run at
  cursor 0. Wire-shape + replay tests updated.
- inbound_turn: RejectedBusy replay fails loud on a malformed stored
  turn_run_id instead of silently dropping it.
- compaction: RejectedBusy + frozen legacy DeferredBusy map to
  SkipEphemeral(StableNonModelVisible), not DeferUntilStable, so busy threads
  compact instead of deferring forever. Integration tests added.
- threads: inject_legacy_deferred_busy_for_test gated behind a test-support
  cargo feature (absent from production builds); filesystem contract coverage
  for mark_message_rejected_busy (happy + invalid transitions).
- webui useChat: always clear processing on rejected_busy, mark the optimistic
  message failed, collision-free system-message id; hook tests added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test/docs(review): RejectedBusy coverage, durable timeline rejection, contract docs

- inbound_turn: regression test for fail-loud malformed RejectedBusy turn_run_id
- product_workflow_contract: RejectedBusy(None) settles + transport retry = Duplicate
- webui wire test: assert status + event_cursor present on fresh RejectedBusy path
- thread contracts (both backends): RejectedBusy -> Submitted resend transition
- webui useChat: persisted rejected_busy/deferred_busy rows now render failed on
  history reload with durable resend copy (was a normal-looking sent message);
  history-messages tests added
- slack_delivery: shared busy-hint path renamed deferred_busy_* -> busy_hint_*
  (fns, call sites, logs, docs); DeferredBusy kept only in the legacy arm
- runtime.rs: inline arch justification above the large RejectedBusy e2e test
- docs/reborn/contracts: product-adapters.md + conversation-binding.md document
  RejectedBusy as a durable terminal outcome; DeferredBusy marked legacy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): openai-compat build break, Slack duplicate hint, conversations doc

- openai-compat-beta: ProductInboundAck::RejectedBusy added to the four
  non-exhaustive match sites (ack_helpers, chat_workflow, responses_workflow x2),
  mapped to the same retryable 429 as DeferredBusy — fixes E0004 that broke any
  build enabling openai-compat-beta. RejectedBusy->429 tests added.
- slack_delivery: busy-hint run-id extraction now unwraps
  Duplicate { prior } recursively, so a transport retry arriving as
  Duplicate { prior: RejectedBusy { Some(run) } } still posts the busy-thread
  hint when the first was lost; the per-(conversation, run_id) throttle
  suppresses genuine repeat posts. Tests added.
- ironclaw_conversations/CLAUDE.md: the idempotency guardrail now distinguishes
  transient submit failures (retry, rotate key) from thread-busy admission
  (terminal RejectedBusy, no retry-until-submitted, user resends).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): RejectedBusy terminal at storage, compaction safety, ledger no-fake-run

- threads: RejectedBusy removed from ensure_user_accepted in both backends —
  a stored RejectedBusy row can no longer transition to Submitted; resend is a
  fresh message. Prior-pass RejectedBusy->Submitted tests inverted to assert the
  transition is now terminal (InvalidMessageTransition). DeferredBusy admission
  kept (legacy replay still resubmits).
- compaction: only RejectedBusy (terminal) is SkipEphemeral; DeferredBusy moved
  back to DeferUntilStable since legacy rows can still reach Submitted — prevents
  a summary silently omitting a message that later becomes model-visible.
- workflow ledger: RejectedBusy { active_run_id: None } maps to
  ActionDispatchKind::NoOp instead of minting a fresh TurnRunId; still settles
  durably, no fabricated run id.
- inbound_turn test: the misnamed legacy-DeferredBusy test now actually injects a
  legacy DeferredBusy row and asserts resubmission, distinct from the RejectedBusy
  re-rejection test.
- openai-compat: added cancel-path RejectedBusy->429 handler test.
- slack_delivery: comments corrected to describe the recursive Duplicate{prior}
  extraction (no longer claim Duplicate{DeferredBusy} returns None).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): non-retryable 429 for terminal RejectedBusy + busy rename + coverage

Address open PR review findings on the busy-thread rejection work:

- openai-compat: split the busy ack arm so terminal RejectedBusy maps to
  429 retryable=false (client must issue a new request), while legacy
  DeferredBusy keeps retryable=true. Covers chat create, responses create,
  and responses cancel paths; regression unit tests on the retryable flag.
- slack_delivery: rename SLACK_DEFERRED_BUSY_* constants to SLACK_BUSY_*
  (path now serves RejectedBusy + legacy DeferredBusy); refresh the stale
  "silently dropped (pending gate)" comment to cover generic RejectedBusy.
- compaction_task: correct the StableNonModelVisible doc comment — only
  terminal RejectedBusy is skipped; legacy DeferredBusy is DeferUntilStable
  (it can still transition to Submitted).
- tests: add filesystem legacy DeferredBusy on-disk round-trip coverage and
  a reborn_services RejectedBusy mark-failure reconcile-via-replay test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(test): update root busy test to terminal RejectedBusy contract

The root integration test product_workflow_retries_after_filesystem_deferred_busy_release
still asserted the legacy DeferredBusy auto-resubmit contract (busy ->
DeferredBusy -> retry resubmits, submission_count 1->2). The live product
workflow now emits terminal RejectedBusy for busy user messages; the PR
updated crate-level tests but missed this root-level one, failing the
"Reborn root tests" CI job.

Rewrite + rename to product_workflow_rejects_busy_and_does_not_resubmit_on_filesystem_replay,
mirroring crates/ironclaw_product_workflow inbound_turn_contract's
rejected_busy_replay_is_re_rejected_not_resubmitted: first ack is
RejectedBusy (submission_count == 1); a same-event replay settles via the
idempotency ledger and returns Duplicate { prior: RejectedBusy } with no
resubmission (submission_count stays 1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): terminal RejectedBusy cut point no longer blocks compaction

Review finding (High): the compaction terminal cut-point validation rejected
every SkipEphemeral disposition with InvalidCutPoint, but RejectedBusy now
classifies as SkipEphemeral(StableNonModelVisible). So a compaction range whose
drop_through_seq landed on a RejectedBusy message hard-failed — contradicting
this PR's goal that terminal RejectedBusy must never block compaction.

Allow a stable-non-model-visible terminal (RejectedBusy) as a legal cut point:
it is excluded from the compacted output like the in-range SkipEphemeral case
and compaction proceeds. Non-User Include and RejectInvalid still error.
Regression test: compaction_port_accepts_terminal_cut_point_that_is_rejected_busy.

Also add legacy_deferred_busy_mark_failure_reconciles_via_replay covering the
reconcile branch's legacy DeferredBusy replay path (RejectedBusy was already
covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): narrow terminal cut-point accept to StableNonModelVisible

Review follow-up: the terminal cut-point arm accepted SkipEphemeral(_) with a
wildcard, which would also admit CapabilityDisplayPreview as a valid terminal.
Only StableNonModelVisible (terminal RejectedBusy) should qualify. Match the
explicit variant so other ephemeral skip reasons fall through to
InvalidCutPoint and fail loud.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(review): reconcile only RejectedBusy as terminal + coverage/naming follow-ups

Address the latest review round (post origin/main merge):

- reborn_services: the mark-failure reconcile path treated legacy DeferredBusy
  as a terminal already-settled state. DeferredBusy is non-terminal (a later
  replay treats it like Accepted and can resubmit), so claiming terminal over
  it violated the no-resubmit guarantee. Drop DeferredBusy from the reconcile
  predicate — only RejectedBusy is terminal; a DeferredBusy row now surfaces
  the mark failure (503 retryable) instead of a false-terminal RejectedBusy.
  Flipped the legacy test to assert the surfaced error.
- compaction: add regression test that a terminal CapabilityDisplayPreview cut
  point returns InvalidCutPoint (only StableNonModelVisible is a legal terminal).
- fakes: FakeProductAdapter now records RejectedBusy in accepted_envelopes
  (durable like Accepted/DeferredBusy) so fake-based tests don't undercount.
- slack_delivery: arch-exempt comment updated from deferred-busy to busy-thread
  / RejectedBusy terminology.
- reborn_services_contract: retext a busy-submit test that asserts RejectedBusy
  but still labeled the path "deferred".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(test): correct scripted-helper comments — DeferredBusy is non-terminal

Follow-up to the reconcile fix: the DeferredBusyMarkFails scripted helper and
its replay branch still documented the old behavior (DeferredBusy "settles"
reconciliation). reconcile_terminal_duplicate now accepts only RejectedBusy as
terminal, so a DeferredBusy replay surfaces the mark error (Unavailable/503)
instead of a false-terminal RejectedBusy. Comment-only; logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(test): align sibling probe-count comments with 3-call reconcile flow

Follow-up: the replay_call_count field doc and rejected_busy_mark_fails() doc
still described the old 2-call probe sequence. Both scripted mark-fail helpers
return None on the first two idempotency probes and Some(..) on the third
(reconcile) probe (count <= 2 guard). Comment-only; logic unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): reject empty cut-point range instead of summarizing nothing

Review finding (Medium): making a terminal RejectedBusy a legal cut point opened
an edge — a range whose only message is that rejection (or any all-skip-ephemeral
span) produced an empty validated_messages, then still ran inference on an empty
prompt and persisted a meaningless summary artifact.

Guard after the deferred-reason check: if validated_messages is empty, return
InvalidCutPoint before build_input — nothing model-visible to summarize. The
deferred-reason early-return stays first so legitimate deferrals are unaffected.
Regression test: a range whose only message is a terminal RejectedBusy returns
InvalidCutPoint and never calls inference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test/docs(review): RejectedBusy command ack, ack_helpers, 429 spec, banner

Address review test/doc gaps on the busy-rejection work:

- product_command_workflow_contract: cover the command-dispatch path where
  command_service returns RejectedBusy -> UnsupportedActionKind -> terminal
  Rejected ack (previously only user-message RejectedBusy was tested).
- ack_helpers: unit-test that internal_refs_from_ack rejects RejectedBusy with
  the internal error (no internal refs bound for a terminal busy ack).
- docs/reborn/contracts/openai-compatible-api.md: document the busy 429 split —
  terminal RejectedBusy is non-retryable (client must issue a new request),
  legacy DeferredBusy stays retryable.
- reborn_services_contract: add the missing opening separator on the Legacy
  DeferredBusy test section banner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compaction): trim summary span to last visible message at a busy cut point

Review finding (Medium): when the compaction cut point is a terminal RejectedBusy,
the summary span ended at drop_through_seq, covering that non-visible message. The
thread backends' context builder skips any ReplaceRangeWhenSelected summary whose
span covers a non-model-context-visible message (summary_covers_hidden_content), so
the summary was persisted but never applied — a dead artifact.

Trim end_sequence to the last model-visible (Include'd) message's sequence so the
span excludes trailing non-visible terminals; the summary then applies. Folds the
empty-range guard into the same `validated_messages.last()` match (None => empty
range => InvalidCutPoint) — no production unwrap/expect. Regression test asserts a
[visible@1, RejectedBusy@2] range compacted through seq 2 yields a summary spanning
end_sequence=1, not 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: pin command RejectedBusy error to ProductAdapterError::Internal

Review nit: the command-RejectedBusy test used a bare expect_err (any error).
Pin the concrete public variant: ProductAdapterError::Internal — which is what
ProductWorkflowError::UnsupportedActionKind maps to at the adapter boundary. The
kind string ("unsupported action kind: ...") is wrapped in RedactedString and
not exposed via Display, so Internal is the tightest assertable pin from the
public return type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(threads): summary may span permanently-terminal non-visible messages

Review finding (Medium, egGm): summary_covers_hidden_content blocked a
ReplaceRangeWhenSelected summary whose span covered ANY non-model-context-visible
message. Compaction legitimately spans non-visible rows it skipped from the
summary content (e.g. an interior terminal RejectedBusy, or a capability preview),
so those summaries were silently dropped — the compaction-layer trailing trim
couldn't fix an interior hole.

Block the summary only when the span covers a non-visible message that can still
RESURFACE as model-visible (Draft / Interrupted / Superseded / DeferredBusy).
Permanently-terminal non-visible rows (RejectedBusy, CapabilityDisplayPreview
kind) never resurface, so spanning them is safe — the summary content already
excludes them and they are never shown in context. Identical change in both
in_memory and filesystem backends via a shared can_resurface_as_model_visible
helper; Redacted/Deleted keep blocking. Also corrects the pre-existing
capability-preview span behavior (two tests updated). Regression tests on both
backends: interior RejectedBusy summary is applied; interior Draft is not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(reborn): allow read-only GitHub capabilities (#4893)

* fix(reborn): allow read-only github capabilities

* test(reborn): future-proof github approval boundary

* fix(reborn): keep GitHub code search gated

---------

Co-authored-by: Robert Yan <mstr.raphael@gmail.com>

* clean up

* delete

* fix: run scheduled automations and report accurate status (#4920)

* fix(automations-ui): readable summary cards and NEXT RUN value

Reflow the summary strip to at most three cards per row so the detail
text no longer wraps one word per line, and let StatCard accept a
valueClassName override so the NEXT RUN date renders at a smaller size
instead of truncating to "Jun…". Default StatCard sizing is unchanged.

* fix(automations-ui): surface delivery save errors and gate Slack hint

The delivery-defaults panel swallowed save/clear failures and showed no
feedback; it now renders an inline error from the mutation and flashes the
"Saved" confirmation on Clear as well as Save. The "reply approve <code> in
Slack" footnote is hidden unless an external Slack-style target exists.

* fix(automations-ui): label sub-hourly cron schedules

Minute- and hour-level cadences such as "* * * * *", "*/15 * * * *", and
"0 * * * *" rendered as "Custom schedule" because they have no single clock
time. They now read as "Every minute", "Every 15 minutes", and "Hourly at
:00".

* fix(automations-ui): space the run-row action button icons

The "Open run" and "Logs" buttons in the recent-runs list rendered the
icon flush against the label because the non-primary Button variants don't
add a gap between children. Add the same icon margin the rest of the app
uses for icon+label buttons.

* fix(automations-ui): consistent summary counts and next-run

The Running/Failures summary cards counted individual runs while the
matching filter tabs counted automations, so the numbers disagreed; both
now count automations. The soonest "Next run" no longer includes paused
triggers, which keep a stored slot they will never actually fire.

* test(automations): lock the panel UI fixes into the served bundle

Add static-asset assertions driving the composed router so each Automations
panel UX fix — sub-hourly cron labels, summary card reflow + smaller NEXT RUN
value, run-row icon spacing, and delivery save-error/Slack-hint gating — is
guarded against a regression that drops it from the shipped SPA source.

* feat(automations): surface scheduler-off state and run it by default on serve

Scheduled automations never fired because the trigger poller is disabled by
default and nothing told the user. The list response now carries
scheduler_enabled (sourced from runtime readiness) and the panel shows a
"scheduling is turned off" notice when it is false. The local `ironclaw-reborn
serve` surface enables the poller by default; config and env still override it.

* fix(i18n): add automations.delivery.saveFailed to every locale pack

The new key was added only to en.js, which breaks the i18n consistency test
that requires all locale packs to share the English key set. Add it to the
ten other packs (English placeholder, matching the existing untranslated
automations strings there).

* fix(automations-ui): clear stale Saved flash before a new delivery write

The save-error alert is gated on !showSaved, so a "Saved" flash still
showing from a prior success would hide the error of a new failing
save/clear. Reset the flash (and its timer) at the start of every attempt.

* fix(automations): harden next-run filter and lock scheduler_enabled on the wire

Use loose `!= null` in the soonest-next-run filter so a missing
next_run_timestamp can't slip through, and assert scheduler_enabled in the
list-automations handler contract test so a serialization drift of the new
field is caught at the wire, not just in the facade.

* fix(i18n): add automations.schedulerOff keys to every locale pack

The scheduler-off notice keys were added only to en.js, which breaks the
i18n consistency test requiring all locale packs to share the English key
set. Add both keys to the ten other packs (English placeholder).

* i18n(automations): translate schedulerOff strings in all locale packs

The scheduler-off notice was English in every non-English pack, giving
Arabic/German/Spanish/French/Hindi/Japanese/Korean/pt-BR/Ukrainian/zh-CN
users a mixed-language UI. Provide real translations.

* i18n(automations): translate delivery.saveFailed in all locale packs

The save-failed delivery error was English in every non-English pack. Provide
real translations so users don't see mixed-language UI when a save fails.

* Localize automation summary counts

* Remove duplicate automation summary locale keys

---------

Co-authored-by: Robert Yan <mstr.raphael@gmail.com>

* fix(approvals): persist "always allow" across threads — drop thread_id from persistent approval scope (#4825) (#4835)

* make 'always allow' approvals persist (tested on google suite)'

* test(approvals): lock criterion-5 backward-compat for project-scoped policies (#4825)

* reduce slop

* fix(approvals): address approval scope review

* fix(approvals): preserve legacy approval lookup

* fix(approvals): find legacy grants across threads

* fix(approvals): drop legacy approval scope compatibility

* fix(approvals): simplify threadless policy lookup

---------

Co-authored-by: Emil Bogomolov <emil.bogomolov@near.ai>
Co-authored-by: Henry Park <henrypark133@gmail.com>

* [codex] Use WebUI base URL for OAuth callback origins (#4932)

* Fix Railway WebUI OAuth callback origin

* fix(reborn-cli): address OAuth base URL review

* fix(reborn-cli): fail closed on hosted oauth base url

* fix(host-runtime): accept empty body/body_base64 in builtin.http (#4827)

* fix(host-runtime): accept empty body/body_base64 in builtin.http

The HTTP tool's `body()` validator rejected any request that carried
*both* a `body` and a `body_base64` field, even when both were empty
strings. The JSON schema lists both fields, so models routinely emit
`{"body": "", "body_base64": "", ...}` as defaults for a bodyless GET.
That tripped the mutual-exclusion check and failed the call with
`InputEncode` before it ever dispatched — on every attempt — so the
agent could never make a successful request and would retry until its
loop gave up.

Treat a null or empty-string field as absent: only a non-empty value
counts as "set", so the mutual-exclusion check fires only when the
caller genuinely supplies two competing bodies. Behavior for a real
`body`, a real `body_base64`, or a genuine both-non-empty conflict is
unchanged.

Adds unit tests for body() covering empty-both, absent, string body,
base64 body, both-non-empty (rejected), and JSON-object body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: rustfmt the body() unit tests

---------

Co-authored-by: Pranav Raja <pranav.raja@near.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(reborn): observability seams — trajectory observer + LLM provider injection (#4588)

* feat(reborn): expose a trajectory observer hook on RebornRuntimeInput

The reborn runtime is sealed: build_reborn_runtime returns only the final
AssistantReply, and per-step capability (tool) calls + results live in internal
stores. Downstream consumers (benchmark harnesses, UI/debuggers) can't observe
the agent's trajectory.

Add `RebornTrajectoryObserver` (pub trait: on_capability_input(call_id, name,
args) / on_capability_result(call_id, output)) and
`RebornRuntimeInput::with_trajectory_observer`. The local-dev capability IO
(`LocalDevCapabilityIo`) forwards each tool call's name+args (at input staging)
and result (at result write) to the observer when present — reusing the same
data it already records for display previews. No-op when unset; best-effort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* debug: trace observer hook firing (temporary)

* feat(reborn): trajectory observer — capability_id on result, reliable spine

Provider tool calls are staged by a lower decorator that bypasses the
LocalDevCapabilityIo input path, so on_capability_input does not fire for
them. on_capability_result fires for every completed capability — make it
carry the capability_id so consumers can reconstruct the trajectory (name +
output) from results alone. Input args capture is a follow-up.

* feat(reborn): capture capability input args at the host port chokepoint

Provider tool calls are staged by ProviderToolCallInputResolver, which keeps
args in a private map and bypasses the capability-IO input hook — so inputs
never reached the trajectory observer (only results did). Move the observer
trait down to ironclaw_loop_support (CapabilityTrajectoryObserver, re-exported
from composition as RebornTrajectoryObserver) and hook it in
HostRuntimeLoopCapabilityPort::invoke_capability right after the input
resolves — the one place the model's resolved arguments are visible. Threaded
through HostRuntimeLoopCapabilityPortFactory + the local-dev factory. Result
hook unchanged. Now name + args + output are all captured.

* feat(reborn): host LLM-provider injection seam

ResolvedRebornLlm::with_provider — drive the runtime with a caller-supplied
LlmProvider (e.g. an instrumented wrapper that counts tokens/cost and captures
reasoning) instead of always building one from config; build_llm_gateway honors
the override. The only viable observability path for reborn, whose model calls
run in spawned worker tasks a per-task tracing subscriber can't reach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(reborn): cover trajectory observer + LLM provider override seams

Addresses Firat's two blocking review findings on #4588 (both
missing-integration-test, per AGENTS.md "test through the caller"):

1. Trajectory observer callbacks — drive the real call sites with a
   recording CapabilityTrajectoryObserver:
   - host port: invoke_capability via HostRuntimeLoopCapabilityPortFactory
     ::with_trajectory_observer asserts on_capability_input fires with the
     resolved capability id + tool-call arguments.
   - local-dev IO: register_provider_tool_call_input + write_capability_result
     assert on_capability_input and on_capability_result fire and correlate by
     input ref.

2. LLM provider override — build_llm_gateway_drives_provider_override_not_config
   injects a counting mock via ResolvedRebornLlm::with_provider, points config
   at a dead endpoint, and asserts the gateway returns the mock's sentinel
   (proving the override is driven, not a config-built chain).

Also fixes pre-existing breakage this surfaced: 5 LocalDevLoopCapabilityPort
Factory test initializers (shell_tests.rs + tests.rs) were missing the
trajectory_observer field added by this PR, so the composition crate's tests
did not compile under --features root-llm-provider.

loop_support: 301 passed; composition (root-llm-provider): 520 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): make trajectory observer input semantics consistent

Addresses Copilot's follow-up findings on the observer seam:

- Drop the `on_capability_input` callback from `LocalDevCapabilityIo::
  register_provider_tool_call_input`. It forwarded the raw provider tool
  name (`builtin_echo`) as the capability id — conflicting with the
  observer contract (resolved dotted `builtin.echo`) and the authoritative
  port-level hook — and `ProviderToolCallInputResolver` doesn't delegate
  here for provider tool calls, so it never fired in practice anyway.
  `HostRuntimeLoopCapabilityPort::invoke_capability` remains the single
  source of `on_capability_input` (resolved id); `LocalDevCapabilityIo`
  remains the source of `on_capability_result`.

- Clarify the trait doc: `arguments` is the raw model-emitted tool-call
  input resolved from the input ref (the callback fires before schema
  normalization), which is what the trajectory should record.

- Refocus the local-dev test on `on_capability_result` forwarding +
  correlation, and assert input staging does NOT emit `on_capability_input`
  from local-dev IO. Port-level input semantics stay covered by the
  capability_port.rs test.

loop_support: 301 passed; composition (root-llm-provider): 520 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* wire trajectory_observer through RefreshingLocalDevCapabilityPortConfig

Completes the main-merge conflict resolution: local_dev.rs passes
trajectory_observer into the refreshing-port config, so the config struct +
port struct must carry it and build_inner must apply it via
.with_trajectory_observer(). (Missed staging this file in the merge commit.)

* test(reborn): lock down the observability seams against regression

#4588 exposes two seams a downstream harness relies on. Add tests so a
future refactor can't silently break either:

- capability_io_forwards_result_to_trajectory_observer: drives
  write_capability_result and asserts on_capability_result fires with the
  correct (call_id, capability_id, output) — the result half of the
  trajectory observer (tool-call outputs).
- build_llm_gateway_drives_provider_override_not_config: asserts the gateway
  drives a provider injected via ResolvedRebornLlm::with_provider (config
  points at a dead endpoint), proving the provider-injection seam works —
  this is how the bench captures reasoning / tokens / cost / system-prompt /
  tool-definitions. (Restores the test dropped during the main merge.)

The input half (on_capability_input) is already covered by
invoke_capability_forwards_resolved_input_to_trajectory_observer in
ironclaw_loop_support. All three pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(reborn): drop the false-confidence result-hook test

capability_io_forwards_result_to_trajectory_observer called
write_capability_result directly, so it stayed green even though the
result hook is unreachable end-to-end while capability dispatch fails
(the LocalDevYolo InputEncode regression) — i.e. it did not fail when
the feature it claimed to cover was actually broken.…
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 size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant