Skip to content

fix(reborn): stale gate projection rows in WebUI stream - #5297

Open
hanakannzashi wants to merge 12 commits into
mainfrom
codex/5200-stale-gate-read-model
Open

hanakannzashi wants to merge 12 commits into
mainfrom
codex/5200-stale-gate-read-model

Conversation

@hanakannzashi

Copy link
Copy Markdown
Contributor

Summary

  • move stale blocked-gate suppression into the turn projection producer before emitting product gate rows
  • keep transient prompt enrichment separate from durable/current gate row projection
  • update the projection contract and regression coverage for stale blocked events

Fixes #5218. Part of #5200.

Testing

  • cargo test -p ironclaw_reborn_composition --features test-support,webui-v2-beta,slack-v2-host-beta,libsql projection::tests::turn_stream -- --nocapture
  • cargo clippy -p ironclaw_reborn_composition --features test-support,webui-v2-beta,slack-v2-host-beta,libsql --all-targets -- -D warnings

Notes

  • Does not change extension search approval behavior; that belongs to the separate tool-permission/global auto-approve path.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

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

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 26, 2026 05:21 Destroyed
@github-actions github-actions Bot added scope: docs Documentation size: M 50-199 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jun 26, 2026
@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 20769cf4-4e0f-4e6b-9b2a-e71b40566358

📥 Commits

Reviewing files that changed from the base of the PR and between c2a121e and 767db2c.

📒 Files selected for processing (2)
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved stale blocked-gate behavior: stale blocked events may show run status, but no longer project gate rows or gate/auth prompt payloads.
    • Tightened blocked-gate projection/prompt emission to only occur when persisted run state matches the blocked event’s status, cursor, and gate identity; failed blocked gates render no prompt.
    • Strengthened allow_always fail-closed: gate prompts require valid approval context.
    • Prevented cross-thread UI updates; composer/send/cancel and processing now apply only to the active thread, and SSE frames from stale threads are ignored.
  • Documentation
    • Clarified events/projections contract rules for gate identity and staleness.
  • Tests
    • Updated blocked-gate projection tests and added chat threading/SSE scoping coverage.

Walkthrough

Blocked gate projection now suppresses stale gate rows from persisted run state, and chat handling now scopes processing, sends, and gate UI to the active thread.

Changes

Blocked gate read-model hardening

Layer / File(s) Summary
Blocked gate projection
crates/ironclaw_reborn_composition/src/projection/turn_events.rs
turn_events.rs now builds CurrentBlockedGateProjection from persisted run state, validates gate identity and cursor, and derives prompt data from the matched blocked event.
Regression and contract text
crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs, docs/reborn/contracts/events-projections.md, crates/ironclaw_reborn_composition/CLAUDE.md
The stale blocked-auth test now expects only run status, and the projection contract notes that stale gate rows are suppressed in the read model.

Chat thread-scoped state

Layer / File(s) Summary
Runtime thread scoping
crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useSSE.js, crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChatEvents.js, crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js, crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
useSSE, useChatEvents, useChat, and Chat now carry thread identity through SSE frames and restrict processing, sending, and gate UI to the active thread.
Thread-scope tests
crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/chat.test.mjs, crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs, crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChatEvents.test.mjs
The chat tests now cover mismatched-thread composer state, cross-thread send admission, and stale SSE frame handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • nearai/ironclaw#5241 — Both PRs change the Reborn WebUI v2 chat approval-gate/send gating logic in useChat.js/chat.js.
  • nearai/ironclaw#5256 — Both PRs modify thread-scoped approval-gate and SSE handling in the chat UI.
  • nearai/ironclaw#5352 — Both PRs update chat send-admission logic to be scoped to the destination/active thread.

Suggested reviewers

  • think-in-universe

Poem

A stale gate knocked, but found no door,
The read model answered: not anymore.
Threads held fast their chosen track,
And stray frames bounced cleanly back.
Quiet state, precise and sure.

🚥 Pre-merge checks | ✅ 2 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description has summary and tests, but it omits most required template sections like Change Type, Validation, Security Impact, and Rollback. Fill the missing template sections, especially Change Type, Linked Issue, Validation, Security Impact, Blast Radius, Rollback, and Review Follow-Through.
Out of Scope Changes check ⚠️ Warning Several WebUI thread-scoping changes in chat.js/useChat/useSSE and related tests are unrelated to #5218's stale gate projection requirements. Move the thread-scoping and SSE/UI event changes to a separate PR unless you can tie them directly to #5218's acceptance criteria.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits style and accurately summarizes the stale gate projection fix in the WebUI stream.
Linked Issues check ✅ Passed The read-model suppression, stale blocked-event regression, and docs update match #5218's scope and no-resurrected-gate requirement.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@railway-app

railway-app Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-5297 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Jun 29, 2026 at 7:03 am

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 26, 2026 09:08 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

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

505-513: 🩺 Stability & Availability | 🟡 Minor

Correct the cross-layer guarantee comment on BlockedExternalTool.

Lines 505–507 claim "External-tool gates are not user-clickable prompts; the OpenAI Responses surface reads them via its own projection path." This overstates exclusivity. Verification shows gate_projection_item emits a ProductProjectionItem::Gate for BlockedExternalTool via the standard projection (mapped to ProductGateKind::Generic with static body text). The comment correctly notes there is no prompt payload, but it misleadingly implies no generic gate row is emitted and that the Responses surface alone owns the projection.

Per coding guidelines: "Comments that promise guarantees across layers must either be enforced by code/tests or softened to describe intent."

Fix options (pick one):

  1. Soften the comment to reflect reality: "External-tool gates have no user-clickable prompt payload; the OpenAI Responses surface may handle them via its own path, but the generic projection still emits a gate row."
  2. Enforce the intended exclusivity by adding an early return in gate_projection_item for ExternalTool (if the design intent is truly to suppress the generic row).

Recommend #1 unless the generic row is confirmed to cause UI drift.

Additional consistency check: Ensure the WebUI correctly distinguishes Generic gates from actionable prompt gates when rendering, or add a filter if external-tool gates should be hidden from the standard gate list.

🤖 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/projection/turn_events.rs` around
lines 505 - 513, The comment around `TurnStatus::BlockedExternalTool` overstates
what the projection does and should be softened. Update the note in
`turn_events::gate_projection_item`/the surrounding `BlockedExternalTool` match
arm to say there is no user-clickable prompt payload, but the generic projection
still emits a `ProductProjectionItem::Gate` (with `ProductGateKind::Generic`)
and the Responses surface may handle it via its own path. If the intent is true
exclusivity, enforce that in `gate_projection_item` with an early return and add
matching tests; otherwise keep the code and adjust the comment only.

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.

Outside diff comments:
In `@crates/ironclaw_reborn_composition/src/projection/turn_events.rs`:
- Around line 505-513: The comment around `TurnStatus::BlockedExternalTool`
overstates what the projection does and should be softened. Update the note in
`turn_events::gate_projection_item`/the surrounding `BlockedExternalTool` match
arm to say there is no user-clickable prompt payload, but the generic projection
still emits a `ProductProjectionItem::Gate` (with `ProductGateKind::Generic`)
and the Responses surface may handle it via its own path. If the intent is true
exclusivity, enforce that in `gate_projection_item` with an early return and add
matching tests; otherwise keep the code and adjust the comment only.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5c13a734-542d-4c89-96f1-4bfc47e7858a

📥 Commits

Reviewing files that changed from the base of the PR and between 5a097f7 and 18d91d8.

📒 Files selected for processing (1)
  • crates/ironclaw_reborn_composition/src/projection/turn_events.rs

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 26, 2026 16:52 Destroyed
@think-in-universe think-in-universe changed the title Fix stale gate projection rows in WebUI stream fix(reborn): stale gate projection rows in WebUI stream Jun 27, 2026
@think-in-universe

Copy link
Copy Markdown
Collaborator

@claude review

@claude

claude Bot commented Jun 27, 2026

Copy link
Copy Markdown

Code Review Summary - PR 5297

Found 9 issues requiring attention before merge.

CRITICAL

  1. CRITICAL:100 - Stringly-typed status field violates type-driven design rule
    ProductProjectionItem::RunStatus uses status: String and test compares against status == blocked_auth
    Per .claude/rules/types.md, fixed small sets must be enums, never string comparisons
    Must change field type from String to an enum variant

  2. CRITICAL:95 - Cursor comparison logic allows stale gates to emit
    Line 405: changed from state.event_cursor != event.cursor to state.event_cursor < event.cursor
    This allows cases where state.event_cursor > event.cursor (state ahead of event) to pass the staleness check
    Gate projection rows are then emitted with stale metadata from prior cursor positions
    Prompt generation correctly gates on state.event_cursor == event.cursor (line 425), creating asymmetry
    The intended suppress stale gates contract is violated when state advances beyond the event

  3. CRITICAL:75 - String interpolation in gate_ref_str into safe_summary risks run termination
    gate_ref is interpolated into ProductOutboundPayload safe_summary via gate_prompt (lines 494-499)
    If gate_ref contains delimiters, validation fails terminally
    Violates agent-loop-capabilities.md Invariant 2 on safe-summary construction

HIGH

  1. HIGH:92 - Missing error propagation in blocked_gate_prompt()
    Line ~487: approval_gate_prompt(...).await returns Result but is assigned directly without unwrapping
    Will cause type mismatch or silent error suppression at runtime

  2. HIGH:88 - Unbounded cursor advancement assumption
    Code assumes state.event_cursor only advances monotonically
    If a turn is replayed, cursor could move backward; less-than check would incorrectly suppress valid gates
    No test covers cursor regression scenarios

  3. HIGH:75 - BlockedGatePromptRequest duplicates TurnLifecycleEvent fields
    Request struct re-carries scope, run_id, owner_user_id, sanitized_reason, status that already exist on event
    Violates architecture rule on context struct field duplication

MEDIUM

  1. MEDIUM:80 - Test doesn't exercise modified cursor comparison logic
    Test only verifies state ahead case; missing tests for state-behind and state-equal scenarios

  2. MEDIUM:75 - Test assertion relies on caller discipline, not type enforcement
    Line 1338: relies on current_blocked_gate_projection returning None for stale gates
    No type-level guarantee prevents future changes from bypassing the invariant

  3. MEDIUM:50 - New projection invariant missing from module spec
    PR doc introduces suppress stale gate rows invariant
    Should be documented in crates/ironclaw_reborn_composition/CLAUDE.md

Summary

Most urgent: (1) Cursor comparison logic allows stale gates, (2) Error handling on approval_gate_prompt fails at runtime, (3) Stringly-typed status field violates type-driven design rule.

These issues block merge. Please address and re-request review.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 27, 2026 11:23 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 27, 2026 11:40 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 27, 2026 18:58 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs (1)

697-704: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not codify an allow-always prompt without context.

This test wires no approval store, so approval_context is absent. The fallback prompt can exist, but allow_always should fail closed just like the projection row below.

As per coding guidelines, “Fail closed for auth, approvals, trust…” applies here.

Proposed test adjustment
                 && prompt.gate_ref == gate_ref.as_str()
                 && prompt.approval_context.is_none()
-                && prompt.allow_always
+                && !prompt.allow_always
🤖 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/projection/tests/turn_stream.rs`
around lines 697 - 704, The GatePrompt assertion in turn_stream test currently
expects allow_always to be true even when approval_context is absent, which
should fail closed. Update the match on ProductOutboundPayload::GatePrompt so
the fallback prompt is still accepted, but allow_always is asserted false when
no approval store/context is present, matching the projection behavior and the
auth/approval fail-closed rule.

Source: Coding guidelines

crates/ironclaw_reborn_composition/src/projection/turn_events.rs (1)

518-535: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed on allow_always when approval context is missing.

Line 532 enables the “always allow” affordance from the gate-ref shape alone. If approval_prompt_lookup() returns no approval_context, the transient GatePrompt can advertise allow_always: true while the projection row correctly disables it at Line 805. That is approval UI drift at a user boundary.

As per coding guidelines, “Fail closed for auth, approvals, trust…” applies here.

Proposed fix
     let lookup =
         approval_prompt_lookup(approval_requests, gate_ref, owner_user_id, &event.scope).await;
+    let allow_always = is_approval_gate_ref(gate_ref.as_str()) && lookup.context.is_some();
     gate_prompt_with_context(
         event,
         gate_ref_string,
         "Approval required",
-        is_approval_gate_ref(gate_ref.as_str()),
+        allow_always,
         lookup.context,
         lookup.invocation_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_reborn_composition/src/projection/turn_events.rs` around
lines 518 - 535, The approval prompt currently enables the “always allow”
affordance based only on the gate-ref shape, which can drift from the persisted
projection when approval context is missing. Update approval_gate_prompt and the
gate_prompt_with_context call site so allow_always is only true when
approval_prompt_lookup returns a real approval_context, and otherwise fail
closed by forcing it off; use the approval_prompt_lookup result and the
is_approval_gate_ref / gate_prompt_with_context path to locate the change.

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.

Outside diff comments:
In `@crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs`:
- Around line 697-704: The GatePrompt assertion in turn_stream test currently
expects allow_always to be true even when approval_context is absent, which
should fail closed. Update the match on ProductOutboundPayload::GatePrompt so
the fallback prompt is still accepted, but allow_always is asserted false when
no approval store/context is present, matching the projection behavior and the
auth/approval fail-closed rule.

In `@crates/ironclaw_reborn_composition/src/projection/turn_events.rs`:
- Around line 518-535: The approval prompt currently enables the “always allow”
affordance based only on the gate-ref shape, which can drift from the persisted
projection when approval context is missing. Update approval_gate_prompt and the
gate_prompt_with_context call site so allow_always is only true when
approval_prompt_lookup returns a real approval_context, and otherwise fail
closed by forcing it off; use the approval_prompt_lookup result and the
is_approval_gate_ref / gate_prompt_with_context path to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b7fc00d6-9377-4b18-b287-35cea7e3eaef

📥 Commits

Reviewing files that changed from the base of the PR and between 504756d and b660ad8.

📒 Files selected for processing (2)
  • crates/ironclaw_reborn_composition/src/projection/tests/turn_stream.rs
  • crates/ironclaw_reborn_composition/src/projection/turn_events.rs

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 28, 2026 05:28 Destroyed
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: M 50-199 changed lines labels Jun 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js`:
- Around line 404-408: The send path still uses the mounted thread state for its
remaining guards, so target-thread sends can be blocked by the currently open
thread. Update the guard logic in send to scope every gating check to the
resolved target thread, using targetRunThreadId alongside threadId and
activeRunBlocksSend. Make sure the early return/null behavior only applies when
the target thread itself is gated or processing, not just the active UI thread.
🪄 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: 3c5f4dac-dfc1-43a6-bd5e-0954f835601f

📥 Commits

Reviewing files that changed from the base of the PR and between b660ad8 and f8bcd75.

⛔ Files ignored due to path filters (1)
  • crates/ironclaw_webui_v2_static/static/dist/app.js is excluded by !**/dist/**
📒 Files selected for processing (7)
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useSSE.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/chat.test.mjs
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChatEvents.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChatEvents.test.mjs

Comment thread crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js Outdated
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 28, 2026 10:52 Destroyed
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 28, 2026 10:56 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js (1)

400-414: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Scope submitBusyRef to the same thread contract.

Line 412 still uses a global submit latch. After this change, a successful send(..., { threadId: otherThread }) can pass Lines 400-414, set submitBusyRef.current = true, and never clear it because this hook only receives onRunSettled for the mounted thread. The next send from the current chat will return null forever.

Suggested fix
-      if (
-        submitBusyRef.current ||
+      if (
+        (sendTargetsCurrentThread && submitBusyRef.current) ||
         (sendTargetsCurrentThread && isProcessingRef.current) ||
         activeRunBlocksSend
       ) {
         return null;
       }
…
-      submitBusyRef.current = true;
+      if (shouldRenderInCurrentThread) {
+        submitBusyRef.current = true;
+      }
…
-        } else if (!response?.run_id) {
+        } else if (!response?.run_id || !shouldRenderInCurrentThread) {
           submitBusyRef.current = false;
         }
🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js` around
lines 400 - 414, The send flow in useChat.js still treats submitBusyRef as a
global latch, which can get stuck when send(..., { threadId: otherThread })
succeeds and the mounted thread never clears it. Update the send/submitBusyRef
logic so it is scoped to the same target thread contract as
sendTargetsCurrentThread and activeRunBlocksSend, and only blocks or resets
submits for the matching thread handled by this hook’s onRunSettled path.
🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js`:
- Around line 400-414: The send flow in useChat.js still treats submitBusyRef as
a global latch, which can get stuck when send(..., { threadId: otherThread })
succeeds and the mounted thread never clears it. Update the send/submitBusyRef
logic so it is scoped to the same target thread contract as
sendTargetsCurrentThread and activeRunBlocksSend, and only blocks or resets
submits for the matching thread handled by this hook’s onRunSettled path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 33bee3da-fa83-475d-b9ad-439d6fb7e127

📥 Commits

Reviewing files that changed from the base of the PR and between f8bcd75 and c9572ec.

⛔ Files ignored due to path filters (1)
  • crates/ironclaw_webui_v2_static/static/dist/app.js is excluded by !**/dist/**
📒 Files selected for processing (2)
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5297 June 28, 2026 10:57 Destroyed
@github-actions github-actions Bot added size: L 200-499 changed lines and removed size: XL 500+ changed lines labels Jun 28, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js`:
- Around line 417-430: The send guard in useChat still treats any current-thread
destination as blocked when isProcessingRef is true, which causes sends to be
dropped even though the composer is enabled for the activeRun/thread mismatch
case. Update the send eligibility logic around activeRunBlocksSend and
processingBlocksSend so it only falls back to threadId before a run has a thread
identity, and once activeRunRef.current has a threadId, compare against the
destination thread identity instead. Add or extend a useChat-level test to cover
the half-state where isProcessing is true but activeRun.threadId differs from
activeThreadId, verifying send is not blocked for the current composer state.
🪄 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: e82043ba-ace2-4076-b2c9-35103a095b4d

📥 Commits

Reviewing files that changed from the base of the PR and between c9572ec and c2a121e.

⛔ Files ignored due to path filters (1)
  • crates/ironclaw_webui_v2_static/static/dist/app.js is excluded by !**/dist/**
📒 Files selected for processing (4)
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/chat.test.mjs
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

Actionable comments posted: 1

🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js`:
- Around line 417-430: The send guard in useChat still treats any current-thread
destination as blocked when isProcessingRef is true, which causes sends to be
dropped even though the composer is enabled for the activeRun/thread mismatch
case. Update the send eligibility logic around activeRunBlocksSend and
processingBlocksSend so it only falls back to threadId before a run has a thread
identity, and once activeRunRef.current has a threadId, compare against the
destination thread identity instead. Add or extend a useChat-level test to cover
the half-state where isProcessing is true but activeRun.threadId differs from
activeThreadId, verifying send is not blocked for the current composer state.
🪄 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: e82043ba-ace2-4076-b2c9-35103a095b4d

📥 Commits

Reviewing files that changed from the base of the PR and between c9572ec and c2a121e.

⛔ Files ignored due to path filters (1)
  • crates/ironclaw_webui_v2_static/static/dist/app.js is excluded by !**/dist/**
📒 Files selected for processing (4)
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/chat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/chat.test.mjs
  • crates/ironclaw_webui_v2_static/static/js/pages/chat/lib/useChat-send.test.mjs
🛑 Comments failed to post (1)
crates/ironclaw_webui_v2_static/static/js/pages/chat/hooks/useChat.js (1)

417-430: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

isProcessing still blocks a current-thread send after the UI says it is sendable.

chat.js now explicitly keeps the composer enabled for { isProcessing: true, activeRun.threadId !== activeThreadId }, but this guard still blocks any send whose destination resolves to threadId. In that half-state, clicking send on the current thread still returns null, so the submit is silently dropped even though the composer is enabled. Only the pre-run_id window should fall back to threadId; once activeRun.threadId exists, this check needs to follow the destination run’s thread identity. The matching hook-level regression is missing too — the new chat.test.mjs case only asserts props.

Suggested fix
-      const processingBlocksSend =
-        isProcessingRef.current &&
-        Boolean(sendTargetThreadId) &&
-        sendTargetThreadId === threadId;
+      const processingBlocksSend =
+        isProcessingRef.current &&
+        Boolean(sendTargetThreadId) &&
+        (!activeRunForSend?.threadId
+          ? sendTargetThreadId === threadId
+          : activeRunForSend.threadId === sendTargetThreadId);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

      const sendTargetThreadId = targetThreadId || threadId;
      const activeRunForSend = activeRunRef.current;
      const activeRunBlocksSend =
        Boolean(activeRunForSend) &&
        Boolean(sendTargetThreadId) &&
        activeRunForSend.threadId === sendTargetThreadId;
      const processingBlocksSend =
        isProcessingRef.current &&
        Boolean(sendTargetThreadId) &&
        (!activeRunForSend?.threadId
          ? sendTargetThreadId === threadId
          : activeRunForSend.threadId === sendTargetThreadId);
      if (
        submitBusyRef.current ||
        processingBlocksSend ||
        activeRunBlocksSend
🤖 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_webui_v2_static/static/js/pages/chat/hooks/useChat.js` around
lines 417 - 430, The send guard in useChat still treats any current-thread
destination as blocked when isProcessingRef is true, which causes sends to be
dropped even though the composer is enabled for the activeRun/thread mismatch
case. Update the send eligibility logic around activeRunBlocksSend and
processingBlocksSend so it only falls back to threadId before a run has a thread
identity, and once activeRunRef.current has a threadId, compare against the
destination thread identity instead. Add or extend a useChat-level test to cover
the half-state where isProcessing is true but activeRun.threadId differs from
activeThreadId, verifying send is not blocked for the current composer state.

@think-in-universe

Copy link
Copy Markdown
Collaborator

@claude review

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Code Review Results

Found Issues

  1. [HIGH:80] Thread identity isolation missing on SSE reconnects

    • useSSE.js:453 adds sourceThreadId to event envelope, but if the SSE connection persists across thread switches, events from a stale thread connection could leak through if the backend doesn't consistently populate sourceThreadId. The filter at useChatEvents.js:716 is documented as "defensive" but this is the primary gate lifecycle protection; it must never fail silently.
    • Recommendation: Add backend test coverage verifying sourceThreadId is populated on every frame from stream_events, not just some. Document the invariant in crates/ironclaw_webui_v2/CLAUDE.md.
  2. [HIGH:75] Race condition between run state fetch and validation

    • turn_events.rs:385-406 fetches the authoritative run state, then at line 405 checks if cursors match. Between the async get_run_state() call and the validation, a concurrent mutation could advance the run state past this gate, leaving a window where the stale-gate suppression check would incorrectly pass. The check is correct in steady-state but the timing window is observable.
    • Recommendation: Document this known window in the CLAUDE.md rule (line 50-53) or consider atomic read-compare-suppress at the run-state service boundary.
  3. [MEDIUM:75] Test consolidation opportunity

    • turn_stream.rs lines 635-707 and 1270-1346 test closely related scenarios: gates without approval context vs. stale gate events with mismatched cursors. Per CLAUDE.md "Consolidate, don't proliferate", these could be one case with multiple assertions (both stale AND missing context) instead of two separate test setups.
    • Impact: Minor code quality; no functional issue.
  4. [MEDIUM:75] Multiple string clones on gate reference in hot path

    • turn_events.rs:424 clones gate_ref_str from gate_ref.to_string(), then line 843 clones it again from blocked_gate_projection.gate_ref. With buffered(16) events at line 274, this is minor but unnecessary allocation in the event stream hot path.
    • Impact: Negligible performance; code clarity improvement only.
  5. [MEDIUM:60] Client-side gate staleness check is presented as defensive but is primary barrier

    • useChatEvents.js:716 filters sourceThreadId on every frame, but the module doc at events-projections.md:761-762 explicitly states client-side checks are "defensive and are not the primary gate lifecycle source of truth." The backend suppression (lines 179-197) is correct, but the frontend appears to be treating the filter as first-line defense, inverting the layering.
    • Recommendation: Ensure every backend frame includes sourceThreadId as documented, and add a debug! log on backend stale-gate suppression for observability.

No Issues Found

  • Authorization bypass on allow_always projection items: the code correctly returns Ok(None) at line 181 when cursors don't match, preventing stale events from emitting projections.
  • Logic errors in cursor/status/gate-ref validation: the three-way check at lines 405-422 is sound and covers the stale-event detection correctly.
  • Cross-thread message delivery: the activeRunBelongsToActiveThread check at chat.js:394-396 and useChat.js:403-404 correctly prevents unrelated runs from blocking the current thread's composer.

Summary: The PR correctly implements the documented stale-gate suppression rule (CLAUDE.md lines 50-53) with proper cursor/status/gate-identity validation. Five minor issues remain: observability of the timing window between fetch and validation, sourceThreadId propagation guarantee, test consolidation, minor allocations, and documentation clarity on defensive vs. primary checks.

This branch was successfully deployed

1 active deployment
ironclaw-ci-preview / ironclaw-pr-5297 — 767db2c1 Deployed Jun 29, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: docs Documentation size: L 200-499 changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: move stale gate suppression into projection read model

2 participants