Repository navigation
fix(gateway): persist user messages at send time (#2409) - #2434
henrypark133 wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request updates the project to version 0.25.0, bumping versions for internal crates and registry artifacts. It implements early user message persistence in the gateway to prevent message loss before the agent loop starts, with the agent loop now checking a metadata flag to avoid duplicates. Message state logic is also refined to mark recent unanswered messages as "Processing" instead of "Failed". Review feedback highlights that early persistence is currently skipped for new threads without an ID and recommends using safer JSON access methods to prevent potential panics when modifying message metadata.
There was a problem hiding this comment.
Pull request overview
Fixes a web gateway race where freshly-sent user messages could disappear on thread switch by persisting the user message to the DB at send time (before returning 202), and preventing duplicate persistence in the agent loop via a gateway-only metadata flag. Also adjusts how incomplete turns are displayed (recent = Processing, stale = Failed) and updates release/registry/versioning metadata for the 0.25.0 release.
Changes:
- Persist gateway user messages to the DB synchronously in
chat_send_handler, and mark messages withuser_message_persistedto dedup in the agent loop. - Update turn building to classify incomplete turns as
Processingif recent (<5 min) andFailedif stale. - Bump versions / release config and update registry artifact URLs and checksums for 0.25.0.
Reviewed changes
Copilot reviewed 23 out of 24 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/channels/web/util.rs | Time-based state for incomplete turns + new unit tests. |
| src/channels/web/server.rs | Early DB persist in chat_send_handler; tests updated/added. |
| src/channels/web/handlers/chat.rs | Update tests to expect Processing for incomplete recent turns. |
| src/agent/thread_ops.rs | Skip duplicate persistence when gateway already wrote the user message. |
| release-plz.toml | Configure tagging/publishing behavior for workspace packages. |
| registry/tools/github.json | Bump tool version and artifact URL/checksum to 0.25.0 release. |
| registry/tools/composio.json | Add wasm artifact URL/checksum for Composio tool. |
| registry/channels/whatsapp.json | Update channel artifact URL/checksum to 0.25.0 release. |
| registry/channels/telegram.json | Update channel artifact URL/checksum to 0.25.0 release. |
| registry/channels/slack.json | Update channel artifact URL/checksum to 0.25.0 release. |
| registry/channels/feishu.json | Update channel artifact URL/checksum to 0.25.0 release. |
| registry/channels/discord.json | Update channel artifact URL/checksum to 0.25.0 release. |
| crates/ironclaw_tui/Cargo.toml | Mark crate as non-publishable. |
| crates/ironclaw_safety/Cargo.toml | Version bump to 0.2.1. |
| crates/ironclaw_safety/CHANGELOG.md | Add crate changelog entry for 0.2.1. |
| crates/ironclaw_gateway/Cargo.toml | Mark crate as non-publishable. |
| crates/ironclaw_engine/Cargo.toml | Update ironclaw_common dependency version reference. |
| crates/ironclaw_common/Cargo.toml | Version bump to 0.2.0. |
| crates/ironclaw_common/CHANGELOG.md | Add crate changelog entry for 0.2.0. |
| channels-src/telegram/src/lib.rs | Formatting-only refactor in message splitting/sending helpers. |
| Cargo.toml | Bump workspace version to 0.25.0; adjust path dependency declarations. |
| Cargo.lock | Lockfile updates for version bumps. |
| CHANGELOG.md | Add 0.25.0 release notes. |
| .github/workflows/release.yml | Restrict release workflow trigger to ironclaw-v* tags. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
fcf9305 to
d782529
Compare
d782529 to
82581e4
Compare
82581e4 to
7a29680
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
7a29680 to
d443ab9
Compare
henrypark133
left a comment
There was a problem hiding this comment.
Review: Persist user messages at send time (Risk: Medium)
Well-designed fix for a real UX bug — early persist at gateway send time with metadata flag dedup is architecturally sound. The module placement follows existing patterns. A few items to address before merge.
Concerning: dispatch-exempt annotation imprecise
File: src/channels/web/server.rs (early-persist block)
The annotation reads // dispatch-exempt: pre-job write, no JobContext exists yet to dispatch through — the actual reason is a structural constraint (no JobContext at the gateway send boundary). Suggest: // dispatch-exempt: no JobContext available at gateway send boundary; dispatcher requires it for audit trail.
Concerning: Early persist bypasses TenantStore ownership layer
File: src/channels/web/server.rs
The early-persist calls store.add_conversation_message() on the raw Arc<dyn Database>, which is the only handler callsite that bypasses TenantStore. The ensure_conversation WHERE clause provides structural protection against cross-tenant writes, but this is a deviation from the established ownership pattern. Consider wrapping with a TenantStore-scoped call, or document why the structural guard is sufficient.
Concerning: 5-minute hardcoded threshold for Processing→Failed
File: src/channels/web/util.rs:99-105
The chrono::TimeDelta::minutes(5) threshold has no relationship to the agent's actual processing timeout. Long-running turns (>5 min) will show "Failed" in UI while still processing. Consider making this configurable or computing from agent turn timeout config.
Concerning: Missing caller-level integration test
The metadata flag dedup predicate in thread_ops.rs gates a DB write side effect, with the flag set in server.rs — this is exactly the wrapper-between-helper-and-side-effect shape that the project's "Test Through the Caller" rule targets. The two DB-tier tests verify the DB layer in isolation but don't exercise the gateway→agent_loop roundtrip. A test using GatewayWorkflowHarness that sends a message and asserts exactly one DB row would catch key-name mismatches between the two files.
Concerning: Ok(false) log message misleading
File: src/channels/web/server.rs
"Early persist skipped: conversation not writable" — Ok(false) from ensure_conversation means the thread belongs to a different user/channel, not a permissions/lock issue. Suggest: "Early persist skipped: thread_id belongs to a different user/channel".
Minor: Tests are libsql-only
Both integration tests are #[cfg(feature = "libsql")] with no PostgreSQL counterpart. Consider mirroring under #[cfg(feature = "integration")].
Minor: 5-minute boundary not tested
The time threshold tests cover 0ms ("Processing") and 10min ("Failed") but not the exact boundary. A test at TimeDelta::minutes(5) would confirm the strict < behavior.
Convention notes:
- Logging levels are correct (
debug!throughout) thiserrorpatterns not changed- No
crate::vssuper::violations
🤖 Generated with Claude Code
- Extract GATEWAY_PERSISTED_FLAG and INCOMPLETE_TURN_THRESHOLD constants to prevent silent key-name mismatches across server.rs, thread_ops.rs, and agent_loop.rs - Fix misleading Ok(false) log message (was "not writable", now correctly says "different user/channel") - Improve dispatch-exempt annotation wording - Skip early-persist when thread is Processing to prevent DB message mis-ordering during concurrent sends - Use TenantScope for add_conversation_message (defense-in-depth ownership) - Add 5-minute boundary test confirming strict < behavior - Add PostgreSQL test counterparts for both early-persist DB tests - Add caller-level metadata flag roundtrip test (covers gateway set + agent loop check + non-gateway channel rejection) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All review comments addressed in fa20833From the self-review (#4102588621):
From Copilot (2nd review):
Changes:
All unit tests pass, clippy clean, both backends compile. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…loop The second drain-loop (ApprovalResponse→downgraded-to-user-input path) cloned the original message without clearing the early-persist flag, which could incorrectly skip DB persistence for queued follow-up content. Addresses Copilot review comment on PR #2434. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # The agent hasn't responded yet, so the turn should be in-progress | ||
| assert turns[0]["state"] in ("Processing", "Failed"), turns[0]["state"] |
There was a problem hiding this comment.
This test assumes the agent hasn’t responded yet and asserts state is only Processing or Failed. In CI the agent loop can complete quickly enough that the first history fetch already returns Completed, which would make this test flaky even though early-persist is working. Consider allowing Completed here (or asserting only that the user turn is present immediately, regardless of state/response).
| # The agent hasn't responded yet, so the turn should be in-progress | |
| assert turns[0]["state"] in ("Processing", "Failed"), turns[0]["state"] | |
| # The user turn must already be persisted; depending on timing, the | |
| # agent may still be processing, may have failed, or may already have | |
| # completed before this first history fetch returns. | |
| assert turns[0]["state"] in ("Processing", "Failed", "Completed"), turns[0]["state"] |
| Ok(true) => { | ||
| let tenant = crate::tenant::TenantScope::new(&user.user_id, store.clone()); | ||
| tenant | ||
| .add_conversation_message(tid, "user", &req.content) |
There was a problem hiding this comment.
This early persist stores the raw gateway request content, but the agent path later augments attachment context before persisting (thread_ops.rs builds effective_content with augment_with_attachments). Because this branch also sets GATEWAY_PERSISTED_FLAG, the agent skips the augmented DB write. That means image-only messages can persist as an empty string, and text+image messages lose the attachment context from durable history if the process crashes or when the DB transcript is reloaded.
Can we either early-persist the same augmented content after images_to_attachments(), or avoid setting the persisted flag when attachments/images are present so thread_ops.rs still persists effective_content? This needs caller-level coverage through /api/chat/send for image-only and text+image messages, not just the flag helper, because the bug is in the wrapper input to the DB write.
serrrfirat
left a comment
There was a problem hiding this comment.
Paranoid Security Review — APPROVE
PR: #2434 — Persist user messages at send time (#2409)
Reviewer context: Automated deep security review
Findings
Excellent tenant isolation and defense-in-depth.
- Authentication enforced:
AuthenticatedUserextractor verifies bearer token before any new code.user_idderived from token, not request body. - Cross-tenant write protection (dual layer):
ensure_conversation()has SQLWHERE user_id = excluded.user_idclause.TenantScope::add_conversation_message()callsconversation_belongs_to_user()before INSERT. - Channel-scoped flag trust:
already_persistedgates onchannel == "gateway"(set server-side) AND flag being true. Non-gateway channels cannot abuse the flag. Explicitly tested. - SQL injection: none. All queries use parameterized placeholders.
- No new
.unwrap()/.expect()in production code. - Pre-safety persistence acceptable: Content is written to DB before safety pipeline runs, but per the project's "LLM data is never deleted" audit-log policy, blocked content in the DB is valuable for security review. Blocked content never reaches the LLM.
Minor notes (not blocking)
- No transaction wrapping
ensure_conversation+add_conversation_message(the agent loop acts as a fallback — no data loss scenario). - TOCTOU between
thread_is_processingcheck and persist is benign (dedup flag catches double-persist, agent loop catches skip).
Test coverage is thorough: 3 unit, 2 flag round-trip, 4 DB integration, 5 E2E Playwright tests. LGTM.
| // Persist user message to DB immediately so it survives crashes. | ||
| // Skip if the gateway already persisted it (indicated by metadata flag). | ||
| // Only trust this flag from the gateway channel to prevent non-gateway | ||
| // channels from skipping persistence by setting the flag themselves. |
There was a problem hiding this comment.
Medium Severity — Architecture
Inverted dependency: agent module imports from channel-specific module.
thread_ops.rs and agent_loop.rs both reference crate::channels::web::util::GATEWAY_PERSISTED_FLAG. This creates a dependency from the core agent module to a specific channel implementation (web gateway), inverting the expected dependency direction (channels depend on agent, not vice versa). If the web gateway is ever feature-gated or extracted, the agent module breaks.
Suggestion: Move GATEWAY_PERSISTED_FLAG to src/channels/mod.rs or add a method on IncomingMessage:
impl IncomingMessage {
pub fn is_gateway_persisted(&self) -> bool {
self.channel == "gateway"
&& self.metadata
.get("user_message_persisted")
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
}This keeps the constant in the channels layer without coupling agent→web, and also addresses Finding #2 (triplicated check logic) by centralizing the check in one place.
| let already_persisted = message.channel == "gateway" | ||
| && message | ||
| .metadata | ||
| .get(crate::channels::web::util::GATEWAY_PERSISTED_FLAG) |
There was a problem hiding this comment.
Medium Severity — Maintainability
Triplicated already_persisted check logic.
The same 5-line pattern appears identically here, in thread_ops.rs:597-602, and is tested inline in two separate test functions. If the check logic ever needs to change (e.g., supporting additional trusted channels), all three sites must be updated in lockstep.
Suggestion: Extract into a single helper (e.g., IncomingMessage::is_gateway_persisted() as described in the companion comment on thread_ops.rs). All three call sites become one-liners, and tests can validate the helper directly.
- Fix attachment data loss: skip GATEWAY_PERSISTED_FLAG when images are present so the agent loop persists augmented content instead of raw text - Extract IncomingMessage::is_gateway_persisted() to eliminate triplicated check logic and fix inverted agent→web channel dependency - Promote early-persist DB failure log from debug! to warn! - Allow Completed state in E2E persistence test to prevent CI flakiness Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review feedback addressed in 0fb8d90
Changes
All unit tests pass, clippy clean, both backends compile. |
- Make INCOMPLETE_TURN_THRESHOLD configurable via AgentConfig and INCOMPLETE_TURN_THRESHOLD_SECS env var (default 300s) - Extract v2_dual_write_user_message() helper from bridge/router.rs and add caller-level libsql integration test verifying DB skip when flag set - Add chat_send_handler integration tests via axum test client: text-only (flag set) and image (flag not set) paths - Add Playwright E2E tests for message persistence across server restart and no-duplicate verification after restart - Move dispatch-exempt annotations to the actual state access lines - Create tracking issue #2456 for stuck turn recovery Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remaining ilblackdragon review feedback addressed in 825bd6a
New tests added
All unit + integration tests pass, clippy clean, both backends compile. |
…itches (#2409) User messages disappeared when switching threads because they were only persisted asynchronously by the agent loop. Now chat_send_handler writes the user message to DB immediately before returning 202, so loadHistory() always finds it. The agent loop skips the duplicate persist via a metadata flag scoped to the gateway channel. Also changes incomplete turn state from "Failed" to time-based: recent turns (<5 min) show "Processing", older unanswered turns show "Failed". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Extract GATEWAY_PERSISTED_FLAG and INCOMPLETE_TURN_THRESHOLD constants to prevent silent key-name mismatches across server.rs, thread_ops.rs, and agent_loop.rs - Fix misleading Ok(false) log message (was "not writable", now correctly says "different user/channel") - Improve dispatch-exempt annotation wording - Skip early-persist when thread is Processing to prevent DB message mis-ordering during concurrent sends - Use TenantScope for add_conversation_message (defense-in-depth ownership) - Add 5-minute boundary test confirming strict < behavior - Add PostgreSQL test counterparts for both early-persist DB tests - Add caller-level metadata flag roundtrip test (covers gateway set + agent loop check + non-gateway channel rejection) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…loop The second drain-loop (ApprovalResponse→downgraded-to-user-input path) cloned the original message without clearing the early-persist flag, which could incorrectly skip DB persistence for queued follow-up content. Addresses Copilot review comment on PR #2434. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tests (#2409) The v2 engine's dual-write path unconditionally called add_conversation_message() without checking the GATEWAY_PERSISTED_FLAG, causing duplicate user messages in the DB. Add the same flag check that v1 uses in thread_ops.rs. Add Playwright E2E tests covering the core persistence behaviors: - User message appears in history immediately after send - Message survives thread switch (the original bug scenario) - No duplicate messages after full agent processing (v1) - No duplicate messages with ENGINE_V2=true (v2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
825bd6a to
bf18421
Compare
|
playwright e2e and good to go |
standardtoaster
left a comment
There was a problem hiding this comment.
The early persist writes user messages to DB before the safety pipeline (scan_inbound_for_secrets, check_policy) runs. The comment acknowledges this, but the message is also immediately visible via the history API. If the safety pipeline subsequently blocks the message, it's already been served to the client. Is there a plan to mark blocked messages in history, or is the assumption that blocked messages are rare enough that this is acceptable?
Closing without mergeAfter review discussion, we're closing this PR. The core issue: the early-persist approach writes user messages to the DB before the safety pipeline ( The existing agent-loop persist at Rather than adding complexity to fix the safety ordering (wiring The Playwright happy-path test ( @standardtoaster Thank you so much for providing feedback on this! |
Summary
loadHistory()finds nothingchat_send_handlernow writes the user message to DB immediately before returning 202, so history queries always find ituser_message_persistedmetadata flag, scoped to the gateway channel only (non-gateway channels can't abuse it)Test plan
cargo fmt— cleancargo clippy --all --all-features— zero warningsbuild_turnsunit tests pass (including new stale/recent split)test_early_persist_writes_user_message_to_db,test_db_allows_duplicate_user_messages_without_flagCloses #2409
🤖 Generated with Claude Code