Fix gateway thread retention and stale in-progress state - #2517
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces durable in-flight turn state, allowing the UI to rehydrate and display "Processing..." indicators for active turns after page refreshes or thread switches. This is achieved by persisting a "live_state" in the conversation metadata and reconciling it with the message history. Feedback includes concerns about potential database bloat from large metadata fields and the efficiency of collecting thread states during sidebar refreshes.
There was a problem hiding this comment.
Pull request overview
This PR addresses two regressions in the durable web-gateway “in-progress” work: (1) the frontend losing the currently viewed thread when it falls outside the 50-thread sidebar window, and (2) /api/chat/history returning stale durable in_progress state after the last turn is already complete.
Changes:
- Frontend: preserve
currentThreadIdacross sidebar refreshes and rehydrate UI “Processing…” state viaHistoryResponse.in_progress. - Server: persist/clear durable
live_statemetadata at turn boundaries and reconcile it against reconstructed DB turns before returning history. - Tests/docs: add Rust + e2e regressions and document the
in_progresshistory contract.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
crates/ironclaw_gateway/static/app.js |
Uses HistoryResponse.in_progress to rehydrate processing UI; preserves active thread during thread-list refresh; adds thread spinners based on server state. |
src/channels/web/server.rs |
Adds InProgressInfo, reads durable metadata, reconciles it with turns, and includes it in /api/chat/history; threads list now merges in-memory state with DB metadata state. |
src/channels/web/types.rs |
Adds HistoryResponse.in_progress and defines InProgressInfo. |
src/agent/thread_ops.rs |
Persists live_state at turn start and clears it on completion/interruption/approval/failure paths. |
src/history/store.rs |
Extends ConversationSummary with live_state extracted from metadata (Postgres store). |
src/db/libsql/conversations.rs |
Extends conversation summary mapping with live_state extracted from metadata (libsql store). |
tests/e2e/scenarios/test_message_persistence.py |
Adds e2e regressions for refresh/switch during in-progress and for sidebar refresh preserving the active thread outside the summary window. |
src/channels/web/CLAUDE.md |
Documents the new HistoryResponse.in_progress contract for UI rehydration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
zmanian
left a comment
There was a problem hiding this comment.
Code Review
Size: XL (+784 / -21) | Risk: Low-Medium
Summary
Fixes two correctness regressions in the durable web-gateway in-progress state:
- Sidebar thread retention -- the frontend was clearing
currentThreadIdwhen the active thread fell outside the 50-thread/api/chat/threadswindow, causing follow-up messages to land on the wrong thread. - Stale in-progress reconciliation --
/api/chat/historycould return stale persistedin_progressmetadata even after the turn was already completed in the DB, causing the UI to show a ghost "Processing..." indicator.
Strengths
-
reconcile_in_progress_with_turnsis the right abstraction. The logic is clear: if the in-progress turn matches the last DB turn and that turn has a response, the in-progress state is stale and gets dropped. If the in-progress turn number is newer than the last DB turn, it's preserved. Clean state machine. -
user_message_idas correlation key is a good choice. Turn numbers can collide across reconnects/compaction, but a DB-generated UUID is stable. The fallback to turn number for non-persistent modes is sensible. -
Frontend fix is minimal and correct. Removing the
currentThreadId = nullfallback when the thread isn't in the sidebar summary is the right call. The first-load fallback toserver_active_threadis preserved. -
Comprehensive test coverage. Unit tests for
reconcile_in_progress_with_turns(both stale and valid cases), integration test with libsql for the full history handler path, E2E Playwright tests for refresh/switch-back/sidebar-overflow scenarios.
Issues
1. live_state metadata is not cleared on AuthPending turns (thread_ops.rs)
The clear_conversation_live_state call is added for Failed, Interrupted, AwaitingApproval, and Completed outcomes. But the AuthPending branch (around line 817 in the original) doesn't clear live state. If an auth-pending turn stays in the DB metadata, a reconnecting client could see stale "Processing..." for an auth-gated turn. This may be acceptable if auth-pending turns don't persist live_state in the first place (they skip persist_user_message in some paths), but worth verifying.
2. user_input truncation at 32KB (thread_ops.rs)
"user_input": truncate_preview(user_input, 32 * 1024),This is stored in conversation metadata JSON. For typical messages this is fine, but if metadata is stored in a TEXT column, 32KB of user input embedded in the metadata JSON could bloat the row. If this is the same metadata field used for thread_type, title, etc., confirm that downstream queries don't have row-size issues. Might be worth a smaller truncation (e.g., 4KB) since this is only for UI preview on reconnect.
3. live_state persisted even on DB write failure (thread_ops.rs)
In persist_user_message, if add_conversation_message fails, the code still persists live_state to metadata:
Err(e) => {
tracing::warn!("Failed to persist user message: {}", e);
let live_state = serde_json::json!({ ... "user_message_id": null ... });
self.persist_conversation_live_state(&store, thread_id, &live_state).await;
None
}If the DB is having issues, the metadata write will likely also fail, which is handled (logged). But the user_message_id: null path means the reconciliation fallback uses turn numbers, which is less reliable. This is fine as a best-effort, but the comment could clarify that this is intentional degradation.
4. Thread state in sidebar uses format!("{:?}", thread.state) (server.rs)
.map(|(id, thread)| (*id, format!("{:?}", thread.state)))This uses Rust's Debug format for ThreadState, which produces strings like "Processing", "Idle", "AwaitingApproval". The frontend checks thread.state === 'Processing'. This coupling between Rust's Debug output and JS string literals is fragile -- if ThreadState variants are renamed or the Debug impl changes, the sidebar spinners break silently. Consider using a display_name() method or serde serialization instead.
5. E2E test AssertionError typo (test_message_persistence.py)
raise AssertionError(f"Timed out waiting for in-progress turn in thread {thread_id}")Should be AssertionError -> this is actually builtins.AssertionError... wait, Python doesn't have AssertionError. This should be AssertionError is not a real Python exception. It should be AssertionError -- actually checking: Python has AssertionError which is NOT a builtin. The correct name is AssertionError... no. The correct Python exception is AssertionError. Let me check: Python has AssertionError which is correct only if it's actually AssertionError. The standard Python assertion error is AssertionError. Actually -- the standard is AssertionError. Hmm, let me re-read: the code says AssertionError. The correct Python builtin is AssertionError.
Wait -- I'm going in circles. The Python builtin is AssertionError. The code says AssertionError. These are the same. I was wrong to flag this. Ignore this point.
Minor
- The
isSameInProgressTurnfunction in app.js mirrorsin_progress_matches_turnin server.rs. Good that both sides agree on the matching logic. live_stateinConversationSummaryis extracted asmetadata.live_state.state(nested), which is correct since the full live_state object has turn_number, user_input, etc.
Overall this is a clean, well-targeted fix. The main concern is item 4 (Debug format coupling) which could cause silent breakage down the road.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
Follow-up on @zmanian’s review: Addressed in follow-ups:
Reviewed but intentionally left out of scope for this patch set:
I re-checked both of those and still do not consider them correctness blockers for this PR:
The |
|
Also addressed the newer The handler no longer returns the earlier |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…n-progress-state # Conflicts: # src/tools/builtin/glob_tool.rs # src/tools/builtin/grep_tool.rs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 16 out of 16 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…n-progress-state # Conflicts: # src/agent/thread_ops.rs # src/channels/web/server.rs
Code Review: PR #2517 — Fix gateway thread retention and stale in-progress stateOverviewFixes two regressions introduced by the durable web-gateway in-progress state work:
Scope: +1207 / -25 across 14 files. The server-side fix is Strengths
Issues & SuggestionsScope creep — unrelated hunksFour unrelated match-exhaustiveness fixes are in this PR but not mentioned in the description:
These look like clippy fallout from a separate rebase. They're harmless but muddy review and Duplicated reconciliation logic (JS ↔ Rust)
The server already filters stale state via Reconciler edge case```rust The Duplication in summary extraction
E2E flakiness risk
Minor
Security & Correctness
VerdictApprove with minor revisions. The core reconciliation and retention logic is sound, well-tested, and correctly addresses both regressions. The main asks are: (1) split the unrelated match-exhaustiveness hunks into their own PR, (2) reduce JS/Rust logic drift around |
Resolve conflicts in crates/ironclaw_gateway/static/app.js and
src/channels/web/server.rs.
app.js loadHistory():
- welcome-card guard now checks both `!data.in_progress` (this PR) and
`freshPending.length === 0` (staging)
- processing indicator branch uses staging's i18n'd
`ActivityEntry.t('activity.processing', 'Processing...')` for both
the new `data.in_progress` path and the legacy `lastTurn.state`
fallback
server.rs test module:
- keep this PR's new `test_gateway_state_with_store_and_session_manager`
helper and the three `test_chat_history_handler_*` integration tests
- keep staging's new `test_auth_manager` helper
- both are purely additive and coexist under the existing `mod tests`
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Preserve the currently open thread even when it falls outside the | ||
| // sidebar's recency window. The history view can still load that thread | ||
| // directly, and follow-up sends must stay attached to it. | ||
|
|
There was a problem hiding this comment.
Preserving currentThreadId when it falls outside the 50-thread summary window can break the read-only thread safety guard: later in loadThreads() the channel is derived from the thread object in the returned list, and if the active thread isn’t present it falls back to treating it as a gateway thread (enabling input). Please ensure the read-only/disabled-input logic remains correct when the active thread is outside the summary list (e.g., keep the last known channel/read-only flag, disable input until resolved, or have the server include the active thread entry even if it’s outside the recency window).
…s enum Producers at 7 sites and consumers at 3 sites previously agreed by convention only. Promotes the status field to a typed enum with snake_case serde — wire format preserved. Maps to bug pattern from #2570, #2531, #2517 where status transitions drifted between producer and consumer. Tests cover snake_case serialization, wire-format round-trip, and the is_success() predicate used at consumer sites.
…ndary External channel thread ids (Telegram chat id, web UUID, Slack thread_ts) flow as raw Option<String> through IncomingMessage, StatusUpdate, and pending-gate store. Wraps them in a validated ExternalThreadId so the compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid). Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion crossed a layer silently.
…s enum (#2678) * refactor(events): replace JobResult.status String with JobResultStatus enum Producers at 7 sites and consumers at 3 sites previously agreed by convention only. Promotes the status field to a typed enum with snake_case serde — wire format preserved. Maps to bug pattern from #2570, #2531, #2517 where status transitions drifted between producer and consumer. Tests cover snake_case serialization, wire-format round-trip, and the is_success() predicate used at consumer sites. * refactor(events): add Stuck variant, accept "error" alias, case-insensitive parse JobResultStatus now covers the full set of wire values producers emit: - New `Stuck` variant for worker/job.rs `mark_stuck` path (was coerced to Failed + warn log, losing the distinction that job monitor and recovery logic care about). - `FromStr` accepts `"error"` as a legacy alias for `Failed` so claude_bridge and acp_bridge wire payloads deserialize cleanly instead of hitting the default-on-unknown branch. - `FromStr` trims whitespace and uses `eq_ignore_ascii_case`, so `" COMPLETED "` and `"Failed"` parse rather than falling back. - Empty / whitespace-only input now returns `Err` (distinct from "unknown value") so callers can log it separately. Producer migration: worker/job.rs emits `JobResultStatus::Stuck` directly via `serde_json::json!` so the wire string stays pinned to `as_str()`. claude_bridge and acp_bridge keep emitting `"error"` on the wire; the FromStr alias covers them without churn on those call sites. Added unit tests for each variant, the `"error"` alias, case insensitivity, whitespace trimming, empty-string error, and preservation of the original input in `JobResultStatusParseError`. --------- Co-authored-by: Henry Park <henrypark133@gmail.com>
…ndary (#2685) * refactor(channels): introduce ExternalThreadId newtype at channel boundary External channel thread ids (Telegram chat id, web UUID, Slack thread_ts) flow as raw Option<String> through IncomingMessage, StatusUpdate, and pending-gate store. Wraps them in a validated ExternalThreadId so the compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid). Maps to bug pattern from #2349, #2444, #2517 where thread-id confusion crossed a layer silently. * fix(bridge): adapt test thread_id to ExternalThreadId newtype Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module. * refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate * refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id - router.rs: scope_thread_id written to PendingGate was wrapped via ExternalThreadId::from_trusted from message.conversation_scope(), which can carry untrusted WASM/metadata-sourced strings. Now validates via ExternalThreadId::new; invalid values log at debug and store None. Applied at both call sites (authentication-fallback path and generic gate-insertion path). - relay/channel.rs: respond() derived thread_id only from response or metadata — now also consults the validated msg.thread_id as the second fallback (before raw metadata) and filters empty strings so we never emit thread_ts: "" to Slack.
* fix(gateway): persist in-progress chat state * Fix gateway thread retention and stale in-progress state * Use stable message IDs for gateway in-progress state * Fix gateway live state review follow-ups * Fix follow-up PR review comments * Fix clippy warning in skills catalog * Fix in-progress review follow-ups * Fix all-features clippy in TUI renderer * Fix legacy in-progress reconciliation * Fix remaining clippy warnings * Fix gateway review follow-ups --------- Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s enum (nearai#2678) * refactor(events): replace JobResult.status String with JobResultStatus enum Producers at 7 sites and consumers at 3 sites previously agreed by convention only. Promotes the status field to a typed enum with snake_case serde — wire format preserved. Maps to bug pattern from nearai#2570, nearai#2531, nearai#2517 where status transitions drifted between producer and consumer. Tests cover snake_case serialization, wire-format round-trip, and the is_success() predicate used at consumer sites. * refactor(events): add Stuck variant, accept "error" alias, case-insensitive parse JobResultStatus now covers the full set of wire values producers emit: - New `Stuck` variant for worker/job.rs `mark_stuck` path (was coerced to Failed + warn log, losing the distinction that job monitor and recovery logic care about). - `FromStr` accepts `"error"` as a legacy alias for `Failed` so claude_bridge and acp_bridge wire payloads deserialize cleanly instead of hitting the default-on-unknown branch. - `FromStr` trims whitespace and uses `eq_ignore_ascii_case`, so `" COMPLETED "` and `"Failed"` parse rather than falling back. - Empty / whitespace-only input now returns `Err` (distinct from "unknown value") so callers can log it separately. Producer migration: worker/job.rs emits `JobResultStatus::Stuck` directly via `serde_json::json!` so the wire string stays pinned to `as_str()`. claude_bridge and acp_bridge keep emitting `"error"` on the wire; the FromStr alias covers them without churn on those call sites. Added unit tests for each variant, the `"error"` alias, case insensitivity, whitespace trimming, empty-string error, and preservation of the original input in `JobResultStatusParseError`. --------- Co-authored-by: Henry Park <henrypark133@gmail.com>
…ndary (nearai#2685) * refactor(channels): introduce ExternalThreadId newtype at channel boundary External channel thread ids (Telegram chat id, web UUID, Slack thread_ts) flow as raw Option<String> through IncomingMessage, StatusUpdate, and pending-gate store. Wraps them in a validated ExternalThreadId so the compiler distinguishes boundary-layer ids from the internal ThreadId(Uuid). Maps to bug pattern from nearai#2349, nearai#2444, nearai#2517 where thread-id confusion crossed a layer silently. * fix(bridge): adapt test thread_id to ExternalThreadId newtype Post-merge fix: a test added in staging (insert_and_notify_pending_gate_uses_extension_manager_for_auth_display_name) assigned a raw String to message.thread_id, but the field type became ExternalThreadId on this branch. Wrap with ExternalThreadId::from_trusted to match the other tests in the same module. * refactor(types): address review feedback — byte units, shared validate, try_-variants, dedup pending-gate * refactor(types): validate scope_thread_id + relay respond prefers typed msg.thread_id - router.rs: scope_thread_id written to PendingGate was wrapped via ExternalThreadId::from_trusted from message.conversation_scope(), which can carry untrusted WASM/metadata-sourced strings. Now validates via ExternalThreadId::new; invalid values log at debug and store None. Applied at both call sites (authentication-fallback path and generic gate-insertion path). - relay/channel.rs: respond() derived thread_id only from response or metadata — now also consults the validated msg.thread_id as the second fallback (before raw metadata) and filters empty strings so we never emit thread_ts: "" to Slack.
Summary
This fixes two correctness regressions introduced by the durable web-gateway in-progress state work:
/api/chat/threadssummary window./api/chat/historycould keep returning stale persistedin_progressmetadata even after the latest turn had already been fully reconstructed from DB messages, causing completed turns to re-render as if they were still processing.Problem
Active thread could be lost during sidebar refresh
The frontend was clearing
currentThreadIdwhenever the active thread was not present in the latest/api/chat/threadsresponse. That response only includes the most recent 50 threads, so a still-valid open thread could disappear from the sidebar summary while the user was actively viewing it.In that case the next render fell back to the server-reported active thread or assistant thread, which meant follow-up messages could be sent to the wrong conversation.
Completed turns could remain stuck in "Processing"
The history handler now rehydrates durable in-progress state from conversation metadata. However, the DB-backed history path returned that metadata directly even when
build_turns_from_db_messages(...)had already reconstructed a completed final turn.That could happen if the browser reloaded between the assistant response write and the metadata-clear write, or if the metadata-clear update failed once. The UI would then:
What changed
Frontend thread retention
In
crates/ironclaw_gateway/static/app.js:currentThreadIdsimply because the current thread was absent from the sidebar summary response/api/chat/threadsThis keeps history loading and follow-up sends attached to the thread the user is actually viewing.
Server-side in-progress reconciliation
In
src/channels/web/server.rs:apply_in_progress_to_turns(...)helper withreconcile_in_progress_with_turns(...)InProgressInfoagainst the reconstructed last turn before returningHistoryResponsein_progressentirely when the persisted turn number matches a last turn that already has a responsein_progresswhen it refers to a newer turn that has not yet been persisted into historyThis ensures durable live-state only survives when it still represents a genuinely incomplete turn.
Tests
Added/updated regression coverage for both paths:
Rust unit/integration coverage
In
src/channels/web/server.rstests:in_progressis dropped when the matching last turn is already completedin_progressis preserved for a newer, not-yet-persisted turn/api/chat/historydoes not return stalein_progressfor a completed persisted turnBrowser regression coverage
In
tests/e2e/scenarios/test_message_persistence.py:Verification
Ran:
cargo fmtcargo clippy --all-targets --all-features -- -D warningscargo test --lib test_chat_history_handler_drops_stale_in_progress_for_completed_turncargo test --lib test_reconcile_in_progress_with_turnsNot run here:
pytest/ Playwright test dependencies installedRisk
Low and targeted.
The frontend change only affects sidebar refresh behavior when a thread is already selected. The server change only affects how durable
in_progressmetadata is reconciled with already-persisted history before the response is returned.