fix(threads): keep the conversation store off the async workers (#5156) - #5282
Conversation
…humansai#5156) `threads_create_new` was exceeding the frontend's 30 s RPC budget and the rejection escaped as an unhandled one: `UnhandledRejection: Non-Error promise rejection captured with value: Core RPC openhuman.threads_create_new timed out after 30000ms` (Sentry TAURI-REACT-10, 18 events / 3 users). Core — why a one-append create took over 30 s. Every `tinycortex::memory::conversations` entry point is synchronous: it takes a process-global `parking_lot::Mutex` and then does fsync'd JSONL IO while holding it, and the per-call cost grows with the user's whole history (`threads.jsonl` is folded from scratch on nearly every operation and gains ~2 lines per appended message, never compacted). `threads/ops.rs` called those entry points inline from its `async fn` handlers, so each one parked a tokio *worker* thread on that mutex — as did the chat hot path (`web_chat::run_task`'s message read) and the per-turn cross-thread search, whose cold index reads every transcript in the workspace. Once more conversation ops are queued than there are workers, the runtime stops polling anything at all, including the HTTP task that owes the client its response. The create was not slow; the executor was starved. Every one of those calls now goes through `memory_conversations::blocking::*`, which runs the store on `spawn_blocking`. The store is exactly as serialized as before — its lock still decides who writes when — but the executor stays live, so a queued create completes when the lock frees instead of after the client has given up. `welcome_migration` stays sync on purpose: one-shot boot migration, not a request path. Frontend — why the failure had nowhere to go. Nothing observed `createNewThread.rejected`, so handling was per-call-site: the shell's Home action caught the rejection and dropped it (dead button, no feedback), and any site that forgot to catch leaked it. `.unwrap()` throws the `rejectWithValue` payload — a bare string, not an `Error`, which is why Sentry recorded it as a non-Error rejection with no frames. The slice now records `createThreadError` for every create path (cleared on retry, success, or dismissal), the chat error strip renders it through the existing `create_thread_failed` code and copy, and both shell hooks log a normalised message instead of swallowing. Tests: blocking-pool wrappers round-trip create/append/read, pass store errors through verbatim, and drive 8 concurrent ops on a single-worker runtime while a cooperative task keeps ticking; slice tests cover the recorded timeout, the catchable `.unwrap()` rejection, the nested `loadThreads` failure shape, and both clear paths; `deriveChatErrorBanner` pins the banner precedence.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
📝 WalkthroughWalkthroughThe frontend now normalizes and displays thread-creation failures, while Rust conversation-store operations run through Tokio’s blocking pool. Tests cover error state transitions, UI precedence, persistence wrappers, scheduling, CRUD behavior, and search. ChangesChat error surfacing
Blocking conversation storage
Estimated code review effort: 4 (Complex) | ~45 minutes Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8757eeecf8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
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 (2)
src/openhuman/threads/ops.rs (1)
136-154: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy liftAdd handler-level regression coverage for the blocking migration.
No tests accompany these changed RPC paths. Add
ops_tests.rscoverage for successful wrapper-backed operations and store-error propagation, especiallythread_create_newand fallback-title persistence; wrapper-only tests would not catch a handler reverting to a synchronous store call.As per coding guidelines, “Untested code is incomplete; add tests for new or changed behavior.”
Also applies to: 179-221, 235-240, 259-277, 283-293, 302-459, 481-491, 505-519, 534-546, 555-564, 611-615
🤖 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 `@src/openhuman/threads/ops.rs` around lines 136 - 154, Add handler-level regression tests in ops_tests.rs for the changed RPC paths, including successful wrapper-backed operations and propagation of store errors. Cover thread_create_new and update_thread_with_fallback_title, verifying fallback titles are persisted, and exercise the other listed handlers so tests invoke handlers rather than only testing storage wrappers.Source: Coding guidelines
app/src/store/threadSlice.ts (1)
116-123: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftDo not report a successful create as a create failure.
Once
threadApi.createNewThread()resolves, a laterloadThreads()failure rejects the whole operation. Callers discard the returned thread, show a “couldn’t create” banner, and a retry can persist duplicate empty threads.
app/src/store/threadSlice.ts#L116-L123: preserve the successfully created thread and make refresh failure non-fatal, or expose it as a distinct reload failure.app/src/store/__tests__/threadSlice.createThread.test.ts#L125-L141: update the test to assert the successful-create behavior while separately validating refresh-error handling.🤖 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 `@app/src/store/threadSlice.ts` around lines 116 - 123, Update the create-thread flow around threadApi.createNewThread and loadThreads so a successful creation always returns the created thread even when refresh fails; handle the reload error separately rather than passing it through formatThreadCreateError as a create failure. In app/src/store/threadSlice.ts lines 116-123, preserve the successful-create result and use the existing reload-error mechanism if available. In app/src/store/__tests__/threadSlice.createThread.test.ts lines 125-141, assert that creation succeeds with the returned thread while validating refresh-error handling separately.
🤖 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 `@app/src/store/threadSlice.ts`:
- Around line 472-482: Track the latest createNewThread requestId in the thread
state when its pending action starts, and in the fulfilled and rejected handlers
only clear or set createThreadError when action.meta.requestId matches that
latest request. Preserve the current error formatting for the active rejection,
and add a test covering an older request rejecting after a newer request
succeeds so the error remains clear.
- Around line 102-109: Update formatThreadCreateError to return error.message
only when it is a non-empty, trimmed string, matching the existing string
validation; otherwise fall back to 'Failed to create thread'. Add a regression
test covering new Error() and verify the rejected reducer preserves a truthy
failure message for deriveChatErrorBanner.
In `@src/openhuman/memory_conversations/blocking_tests.rs`:
- Around line 85-130: Make
concurrent_operations_complete_without_stalling_the_single_async_worker
deterministic by adding a deliberately blocked store run closure and
synchronization signaling when the ticker makes progress. Await the ticker’s
progress signal while the closure remains blocked, and wrap that wait in a
bounded timeout so inline store execution fails; release the closure afterward
and preserve the existing concurrent thread assertions.
---
Outside diff comments:
In `@app/src/store/threadSlice.ts`:
- Around line 116-123: Update the create-thread flow around
threadApi.createNewThread and loadThreads so a successful creation always
returns the created thread even when refresh fails; handle the reload error
separately rather than passing it through formatThreadCreateError as a create
failure. In app/src/store/threadSlice.ts lines 116-123, preserve the
successful-create result and use the existing reload-error mechanism if
available. In app/src/store/__tests__/threadSlice.createThread.test.ts lines
125-141, assert that creation succeeds with the returned thread while validating
refresh-error handling separately.
In `@src/openhuman/threads/ops.rs`:
- Around line 136-154: Add handler-level regression tests in ops_tests.rs for
the changed RPC paths, including successful wrapper-backed operations and
propagation of store errors. Cover thread_create_new and
update_thread_with_fallback_title, verifying fallback titles are persisted, and
exercise the other listed handlers so tests invoke handlers rather than only
testing storage wrappers.
🪄 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
Run ID: c0ecd34c-e278-4b4d-889e-e2a22eafff05
📒 Files selected for processing (15)
app/src/components/layout/shell/useHomeNav.test.tsxapp/src/components/layout/shell/useHomeNav.tsapp/src/components/layout/shell/useNewChat.test.tsxapp/src/components/layout/shell/useNewChat.tsapp/src/features/conversations/Conversations.tsxapp/src/pages/__tests__/Conversations.test.tsxapp/src/store/__tests__/threadSlice.createThread.test.tsapp/src/store/threadSlice.tssrc/openhuman/agent_memory/memory_loader.rssrc/openhuman/memory_conversations/blocking.rssrc/openhuman/memory_conversations/blocking_tests.rssrc/openhuman/memory_conversations/mod.rssrc/openhuman/threads/README.mdsrc/openhuman/threads/ops.rssrc/openhuman/web_chat/run_task.rs
…eview Four findings from the tinyhumansai#5282 review. **Cancellation safety (`threads/ops.rs`).** Moving the store onto the blocking pool introduced a yield point that did not exist before: `spawn_blocking` work is never cancelled when its `JoinHandle` is dropped, so a caller that goes away mid-delete left the thread gone from the store while everything after the await was skipped — web-session invalidation, sub-agent cancellation, turn-snapshot deletion. The session could then append to a thread index row that no longer exists, sub-agents kept queueing completions, and the snapshot resurfaced as `Interrupted` for a thread that is gone. `run_to_completion` owns the mutation and its cleanup in one spawned task so the tail runs regardless of the caller; `thread_delete` and `threads_purge` both route through it. The ambient `CoreContext` is carried across explicitly — a bare `tokio::spawn` drops the `task_local` scope and silently falls back to the process default, which under multi-tenant dispatch is the wrong workspace. **Stale create completions (`store/threadSlice.ts`).** Two overlapping creates could leave the banner wrong: request B succeeds and clears it, then request A times out and repaints "Couldn't create a new thread" over a chat that exists. `createThreadRequestId` records the latest started attempt and only that one may write the create-error state. **Empty `Error` messages.** `new Error()` has `message === ''`, so `formatThreadCreateError` stored a falsy `createThreadError` and `deriveChatErrorBanner` rendered nothing — the dead New Chat button with no banner, exactly what tinyhumansai#5156 exists to prevent. Blank messages now fall through to the generic fallback. **Deterministic starvation test (`memory_conversations/blocking_tests.rs`).** The ticker's 64 yields could all retire before any store task reached the lock, so the test stayed green against an inline implementation. It now holds a store closure open for the whole window and requires the ticker to complete while it is held. The watchdog is a plain OS thread, not `tokio::time::timeout`: under an inline implementation the sole worker is parked inside the closure, so a timeout future would never be polled and the test would hang rather than fail. It now fails in ~10s with "only 0/64 ticks retired", and always releases the probe so a starved runtime recovers far enough to report. Regression tests for all four, each verified failing against the previous code.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
src/openhuman/memory_conversations/blocking_tests.rs (1)
183-199: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a note that the synchronous
watchdog.join()depends on themulti_threadflavor.The blocking
join()runs on theblock_onthread, so the single worker stays free to poll the ticker/probe. That correctness is entirely load-bearing onflavor = "multi_thread"; switching tocurrent_threadwould deadlock for 10s and then fail with a misleading message. A one-line comment (or adebug_assert-style remark) tying the join to the flavor choice would protect the invariant.🤖 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 `@src/openhuman/memory_conversations/blocking_tests.rs` around lines 183 - 199, Add a concise comment immediately before watchdog.join() explaining that the synchronous join relies on the test’s multi_thread runtime flavor, because the block_on worker must remain available to poll the ticker and probe. Tie the note to the existing runtime flavor configuration without changing the test flow.src/openhuman/threads/ops_tests.rs (1)
728-734: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider covering the
CoreContextpreservation branch too.This test exercises the cancellation contract but only the
Nonecontext path. The stated motivation for capturingCoreContext::current()is that a baretokio::spawnwould silently fall back to the process-default workspace — that regression would still pass here. A second case that sets a context viaCoreContext::scope, callsrun_to_completion, and asserts the inner future observes the same workspace would lock in the multi-tenant behavior.As per coding guidelines, "Untested code is incomplete; add tests for new or changed behavior."
🤖 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 `@src/openhuman/threads/ops_tests.rs` around lines 728 - 734, Extend the cancellation test around run_to_completion with a CoreContext::scope case that installs a non-default workspace, runs an inner future through run_to_completion, and asserts the future observes that same workspace. Keep the existing cleanup and cancellation assertions intact, and specifically verify context propagation rather than only the None-context path.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 `@src/openhuman/threads/ops.rs`:
- Around line 102-123: Update run_to_completion so the spawned task logs any Err
returned by fut before propagating it, ensuring failures remain visible when the
caller drops the JoinHandle. Keep the existing context scoping and join-error
handling, but distinguish panic-induced join failures from ordinary task
failures in the warning rather than presenting a panic as a generic join fault.
---
Nitpick comments:
In `@src/openhuman/memory_conversations/blocking_tests.rs`:
- Around line 183-199: Add a concise comment immediately before watchdog.join()
explaining that the synchronous join relies on the test’s multi_thread runtime
flavor, because the block_on worker must remain available to poll the ticker and
probe. Tie the note to the existing runtime flavor configuration without
changing the test flow.
In `@src/openhuman/threads/ops_tests.rs`:
- Around line 728-734: Extend the cancellation test around run_to_completion
with a CoreContext::scope case that installs a non-default workspace, runs an
inner future through run_to_completion, and asserts the future observes that
same workspace. Keep the existing cleanup and cancellation assertions intact,
and specifically verify context propagation rather than only the None-context
path.
🪄 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
Run ID: 4859a2cf-d529-4b1b-8e84-7333f0bcdbd7
📒 Files selected for processing (5)
app/src/store/__tests__/threadSlice.createThread.test.tsapp/src/store/threadSlice.tssrc/openhuman/memory_conversations/blocking_tests.rssrc/openhuman/threads/ops.rssrc/openhuman/threads/ops_tests.rs
Summary
threads_create_newno longer waits behind a starved runtime: every conversation-store call on a request path now runs on tokio's blocking pool (memory_conversations::blocking::*) instead of parking an async worker thread on the store's process-global mutex.web_chat::run_taskand the per-turn cross-thread search inagent_memory::memory_loader, whose cold index reads every transcript in the workspace.thread.createThreadError), so it can no longer escape as an unhandled promise rejection or vanish silently.create_thread_failedcode and copy, so the shell's "New chat" / Home actions stop being dead buttons on failure.threads/README.mddocuments the rule (and its one deliberate exception) so the sync entry points don't creep back into request paths.Problem
Sentry TAURI-REACT-10 (#5156), 18 events / 3 users,
openhuman@0.61.8:Two independent defects meet here.
1. The create was not slow — the executor was starved
Every
tinycortex::memory::conversationsentry point is synchronous. Each one takes a process-globalparking_lot::Mutexand then does fsync'd JSONL file IO while holding it:thread_index_unlockedfolds the whole ofthreads.jsonlon nearly every operation, and that file gains ~2 lines per appended message and is never compacted — so the per-call cost grows with the user's entire history;append_messageperforms two fsync'd appends under the lock, andupdate_messagereads and rewrites a thread's whole message log under it, so a live streaming turn takes the lock over and over;search_cross_thread_messagesreads every thread's transcript on a cold index.threads/ops.rscalled those entry points inline from itsasync fnhandlers.parking_lot::Mutex::lock()is a blocking lock, so each call parks a tokio worker thread for the whole wait — and so didweb_chat::run_taskand the per-turn memory loader. Once more conversation operations are queued than there are worker threads, the runtime stops polling anything, including the HTTP task that owes the client its response.So a create that needs one append can blow a 30 s budget while doing nothing: it is waiting for a worker to be free to poll it. That also explains the shape of the report — rare (3 users), and concentrated on users with enough history to make each fold expensive.
2. The failure had nowhere to go
Nothing in the store observed
createNewThread.rejected. Handling was therefore per-call-site, and the two shell entry points diverged in opposite failure modes:useHomeNavdid.catch(() => {})— Home did nothing, said nothing;.catchleaked the rejection.And
.unwrap()throws therejectWithValuepayload — a bare string, not anError— which is exactly why Sentry recorded a "Non-Error promise rejection" with no app frames. The same class was fixed once before forthreads_list(formatThreadLoadError, OPENHUMAN-REACT-X); this is that fix's missing sibling.Solution
Core:
memory_conversations::blockingA thin module of
spawn_blockingwrappers (ensure_thread,list_threads,get_messages,append_message,update_message,update_thread_title,update_thread_labels,delete_thread,purge_threads,search_cross_thread_messages). All 15 store calls inthreads/ops.rsplus the two other async hot paths go through them.The store is exactly as serialized as before — its lock still decides who writes when. What changes is where the wait happens: on the blocking pool instead of on a scarce async worker. The executor stays live, so the RPC server keeps answering while a slow conversation operation drains, and a queued create completes as soon as the lock frees rather than after the client has given up. A
JoinErroris surfaced as a store error, so a pool panic can't reach the dispatcher as an unknown failure.welcome_migration.rsdeliberately stays synchronous: it is a one-shot, marker-guarded boot migration, not a request path.Frontend: one recorded outcome per create
threadSlicegainscreateThreadError, set oncreateNewThread.rejectedand cleared onpending/fulfilled/ explicit dismissal.formatThreadCreateErrornormalises every throw shape (rejectWithValuestring,Error,SerializedError) — including the nestedloadThreads().unwrap()failure inside the thunk, which the olderror instanceof Errorcheck flattened into a generic message.Conversationsrenders it via the exportedderiveChatErrorBanner(send error wins; create failure otherwise), and both shell hooks log a normalised message instead of swallowing.Impact
threads.jsonl's unbounded growth and per-call refold is the underlying latency driver, but it lives in the vendoredtinycortexsubmodule; fixing it needs its own PR there plus a submodule bump. This PR removes the starvation, not the fold.Submission Checklist
memory_conversations::blocking) and Vitest suites listed below;pnpm typecheckand the targeted Vitest runs are green locally, Rust compiles in CIN/A: behaviour-only change(no feature row added, removed, or renamed)## Related—N/A: no matrix feature IDs affectedTempDirworkspaces and the existingthreadApimock)N/A: does not touch release-cut surfacesCloses #NNNin the## RelatedsectionTests:
memory_conversations::blocking— create/append/read round-trip; store errors pass through verbatim (a missing thread still yields the store's ownnot found, so the RPC layer's thread-scoped error mapping keeps working); title/labels/delete/purge round-trip;update_message+ cross-thread search; and 8 concurrent operations on aworker_threads = 1runtime completing while a purely cooperative task keeps being polled — the starvation shape from the report.threadSlice.createThread.test.ts— the RPC timeout is recorded in state;.unwrap()rejects catchably and normalises back to the original message; the failure clears on retry, on success, and on dismissal; a failure originating in the follow-uploadThreadsis recorded with its own message;formatThreadCreateErrorcovers string /Error/SerializedError/ message-less shapes.Conversations.test.tsx—deriveChatErrorBannerprecedence: create failure surfaces with thecreate_thread_failedcode, a live send error wins over a stale create failure, neither present renders nothing.Related
formatThreadLoadError, the same non-Error rejection class fixed earlier forthreads_list); tinycortex issue memory_conversations: first cross-thread search holds both locks during JSONL rebuild #2849 (the cold-index lock work this builds on)threads.jsonlintinycortexso the per-operation fold stops scaling with total historyAI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
fix/5156-rpc-threads-create-timeout8757eeecfValidation Run
pnpm --filter openhuman-app format:check— Prettier clean on every touched filepnpm typecheck— cleanthreadSlice.createThread.test.ts(8),pages/__tests__/Conversations.test.tsx(15),src/components/layout/shell+store/__tests__/threadSlice.test.ts(92),src/features/conversations(301) — all passingcargo fmt --checkclean; compilation + the newblockingsuite run in CIapp/src-taurichangeValidation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
Parity Contract
welcome_migrationintentionally keeps its synchronous boot-time path.ThreadsError::from_thread_scoped_store_errorstill receives the store's own error text (pinned by a test); the create-failure banner reuses the existingcreate_thread_failedcode and copy rather than introducing a new surface.Duplicate / Superseded PR Handling
Summary by CodeRabbit