Drain DeferredBusy messages when the blocking run reaches terminal state - #4812
henrypark133 wants to merge 21 commits into
Conversation
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>
📝 WalkthroughWalkthroughAdds 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. ChangesDeferred-Busy Drain Flow
Repository Hygiene
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)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Suggested reviewers
No CLAUDE.md/AGENTS.md/.claude/rules invariant violations detected.
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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.
| let oldest = match deferred.into_iter().next() { | ||
| Some(m) => m, | ||
| None => return Ok(()), | ||
| }; |
There was a problem hiding this comment.
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:
- 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.
- 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 theDeferredBusystate, andcontinuethe loop to try the next message. - If a message is successfully submitted, mark it as submitted and
breakthe loop (as the new run will eventually trigger the next drain). - If submission fails with
ThreadBusy, leave it deferred andbreakthe loop.
References
- 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.
There was a problem hiding this comment.
💡 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".
| /// State-based observation is not needed; drain is driven by committed | ||
| /// events only. | ||
| fn observes_state(&self, _state: &TurnRunState) -> bool { | ||
| false |
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
- 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
- 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) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
|
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. |
…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>
|
Review comments addressed in 242cf33..37dbd35:
clippy zero warnings; 877 tests pass (pre-existing |
There was a problem hiding this comment.
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 liftDon't drop
mission_idwhen rebuildingThreadScope.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 bymission_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 canonicalThreadScopebefore 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 winAssert 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
alphathenbeta.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
📒 Files selected for processing (8)
crates/ironclaw_product_workflow/src/binding_ref.rscrates/ironclaw_product_workflow/src/lib.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/filesystem_service.rscrates/ironclaw_threads/src/in_memory.rscrates/ironclaw_threads/tests/filesystem_session_thread_contract.rscrates/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
left a comment
There was a problem hiding this comment.
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
- 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 withsrc:andreply: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 aswebui-src/webui-replyor 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. - 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
- 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_messagesacceptslimit, and the drain calls it withSome(8), but the filesystem backend first callslist_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
- 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 dropsadditional_required_observersor failsbind_coordinatorin the production runtime path would not be caught. - 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. - 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 catcheslist_deferred_busy_messagesfailures 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. - 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
- 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 ause, 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. - 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 saystracingis available transitively via other crates, but this crate already declarestracing = "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( |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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], |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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) => { |
There was a problem hiding this comment.
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)) => { |
There was a problem hiding this comment.
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
…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>
…, 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>
|
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):
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. |
There was a problem hiding this comment.
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 winRemove the deferred-busy marker when a thread is deleted.
mark_message_deferred_busyinserts(ThreadScope, ThreadId)intodeferred_threads, butdelete_threadonly removes the thread record and idempotency entries. Any thread that ever hitDeferredBusyleaves a dead marker behind, so create/delete churn can grow thisHashSetwithout 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 winAdd 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 thatafter_sequenceskips the older window before ordering andlimitare 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 winAdd explicit contract coverage for
after_sequencepagination semantics.The new section exercises
limit/ordering well, but every request usesafter_sequence: None(e.g., Line 2537, Line 2597, Line 2646). Add one test that setsafter_sequenceand 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
📒 Files selected for processing (17)
crates/ironclaw_loop_support/src/compaction_task.rscrates/ironclaw_loop_support/src/subagent_spawn_port/tests.rscrates/ironclaw_loop_support/tests/thread_loop_support_contract.rscrates/ironclaw_product_workflow/src/inbound_turn.rscrates/ironclaw_product_workflow/src/reborn_services.rscrates/ironclaw_product_workflow/tests/reborn_services_contract.rscrates/ironclaw_reborn/src/loop_exit_applier/tests/mod.rscrates/ironclaw_reborn/src/subagent/completion_observer.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain.rscrates/ironclaw_reborn_composition/src/runtime.rscrates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/filesystem_service.rscrates/ironclaw_threads/src/in_memory.rscrates/ironclaw_threads/src/service.rscrates/ironclaw_threads/tests/filesystem_session_thread_contract.rscrates/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
henrypark133
left a comment
There was a problem hiding this comment.
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
- 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
- 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
- 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. - 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. - 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. - 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. - 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
- 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
- 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. - 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
- 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 02f1f18 — see crates/ironclaw_threads/src/filesystem_service.rs.
| spawn_tree_root_run_id: None, | ||
| }; | ||
|
|
||
| match coordinator.submit_turn(request).await { |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in f2600cc — see crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs.
| &thread_scope, | ||
| &handoff, | ||
| &client_action_id, | ||
| canonical_source_ref, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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::{ |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Fixed in f2600cc — see crates/ironclaw_product_workflow/src/lib.rs.
| } | ||
| }; | ||
|
|
||
| // Use the canonical refs persisted at defer time. Records written |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in f2600cc — see crates/ironclaw_reborn_composition/src/deferred_busy_drain.rs.
…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
left a comment
There was a problem hiding this comment.
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
- 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
- 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. - 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
- 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. - 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
- 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
- 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. - 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
- 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. - 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. - 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in ba5f6ed — drain_mark_submitted_error_returns_ok_after_accept added.
| ); | ||
| return Ok(()); | ||
| } | ||
| Err(error) => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Tracked in #4833 (per-thread deferred index).
| scope: &ThreadScope, | ||
| thread_id: &ThreadId, | ||
| message_id: ThreadMessageId, | ||
| turn_source_binding_ref: Option<String>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Deferred — mechanical test/module split; skipping pre-merge churn.
| @@ -0,0 +1,1982 @@ | |||
| /// Drains [`MessageStatus::DeferredBusy`] messages when a blocking run reaches | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in ba5f6ed — file overview converted to inner //! docs.
| return Ok(()); | ||
| } | ||
| } | ||
| // The submit succeeded — the `return Ok(())` above means we only |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in ba5f6ed — comment reworded to describe the skip-only reality.
…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>
…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
left a comment
There was a problem hiding this comment.
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
- 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. - 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. - 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
- 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. - 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
- 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
- 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. - 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
- 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. - 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
- 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Tracked in #4831 — persisted skip/non-drainable state for permanently invalid records.
| thread_scope.owner_user_id.clone(), | ||
| ); | ||
|
|
||
| let request = SubmitTurnRequest { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 18bb5b3 — docs now describe coordinator replay with persisted canonical refs.
| @@ -0,0 +1,27 @@ | |||
| //! Integration tests for `DeferredBusyDrainObserver`. | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Fixed in 18bb5b3 — no-op e2e target deleted; coverage lives in the inline drain tests + runtime wiring test.
…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>
… into feat/deferred-busy-drain
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
crates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/filesystem_service.rscrates/ironclaw_threads/src/in_memory.rscrates/ironclaw_threads/tests/filesystem_session_thread_contract.rscrates/ironclaw_threads/tests/session_thread_contract.rs
| 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(()); |
There was a problem hiding this comment.
🎯 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
There was a problem hiding this comment.
Tracked in #4831 — persisted skip/non-drainable state for permanently invalid records (regression test specified there).
There was a problem hiding this comment.
@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>
There was a problem hiding this comment.
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 winAdd 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_filejustification 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
📒 Files selected for processing (7)
crates/ironclaw_product_workflow/src/inbound_turn.rscrates/ironclaw_product_workflow/src/reborn_services.rscrates/ironclaw_product_workflow/tests/inbound_turn_contract.rscrates/ironclaw_product_workflow/tests/product_workflow_contract.rscrates/ironclaw_product_workflow/tests/reborn_services_contract.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rscrates/ironclaw_threads/src/filesystem_service.rs
henrypark133
left a comment
There was a problem hiding this comment.
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
- 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
- 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. - 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. - 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
- 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
- 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. - 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. - 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. - 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
- 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. - 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
- 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. - 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
- 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
- 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| /// | ||
| /// 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( |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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> { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Tracked in #4833 — per-thread deferred index honoring limit before transcript reads.
| event_cursor, | ||
| }) | ||
| } | ||
| Err(TurnError::ThreadBusy(_)) => Ok(RebornSubmitTurnResponse::DeferredBusy { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| @@ -0,0 +1,2054 @@ | |||
| use std::sync::Arc; | |||
There was a problem hiding this comment.
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.
| { | ||
| Ok(messages) => messages, | ||
| Err(error) => { | ||
| warn!( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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>
There was a problem hiding this comment.
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 winGive 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 smallThreadScopeDerivationErrorenum 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
📒 Files selected for processing (8)
crates/ironclaw_product_workflow/src/inbound_turn.rscrates/ironclaw_product_workflow/src/reborn_services.rscrates/ironclaw_product_workflow/tests/inbound_turn_contract.rscrates/ironclaw_product_workflow/tests/reborn_services_contract.rscrates/ironclaw_reborn/src/thread_scope.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rscrates/ironclaw_threads/src/filesystem_service.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>
There was a problem hiding this comment.
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 winFail loud on deferred-mark write failures.
Line 2766 only verifies
MessageStatus::DeferredBusy. Ifmark_message_deferred_busy(...)returns after flipping status but before persistingturn_source_binding_ref,turn_reply_target_binding_ref, orturn_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 liftThis regression still bypasses the real deferred-busy caller.
The test reconstructs the deferred state with direct
accept_inbound_message/submit_turn/mark_message_deferred_busycalls, 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 producesDeferredBusy, 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
📒 Files selected for processing (19)
crates/ironclaw_loop_support/src/compaction_task.rscrates/ironclaw_loop_support/src/subagent_spawn_port/tests.rscrates/ironclaw_loop_support/tests/thread_loop_support_contract.rscrates/ironclaw_product_workflow/src/inbound_turn.rscrates/ironclaw_product_workflow/src/reborn_services.rscrates/ironclaw_product_workflow/tests/reborn_services_contract.rscrates/ironclaw_reborn/src/loop_exit_applier/tests/mod.rscrates/ironclaw_reborn/src/subagent/completion_observer.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/mod.rscrates/ironclaw_reborn_composition/src/deferred_busy_drain/tests.rscrates/ironclaw_reborn_composition/src/runtime.rscrates/ironclaw_reborn_composition/src/trigger_poller_trusted_submit.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/filesystem_service.rscrates/ironclaw_threads/src/in_memory.rscrates/ironclaw_threads/src/service.rscrates/ironclaw_threads/tests/filesystem_session_thread_contract.rscrates/ironclaw_threads/tests/session_thread_contract.rscrates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs
henrypark133
left a comment
There was a problem hiding this comment.
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
- 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. - 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
- 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
- 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. - 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
- 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. - 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. - 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. - 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
- 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
- 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. - 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() { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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())); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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")] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
|
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. |
* 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(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.…
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 asMessageStatus::DeferredBusywith 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 viaadditional_required_observers(same pattern asSubagentCompletionObserver, incl.bind_coordinatorback-reference). On every terminal event (is_terminal()— the exact inverse ofkeeps_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:
actor_id; a record without one is left deferred rather than misattributed to the thread owner.drain:<message_id>— a duplicate terminal event replays the same run instead of double-submitting;mark_message_submittedflips the record so the product replay path returnsAlreadySubmitted.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
TurnCoordinatordirectly 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 astrigger_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 throughInboundTurnServicereplay, that's a structural follow-up worth discussing on this PR.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Chores / Tests