[Bug]: A user message can become a later run than an agent message sent after it#24335
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
The replay fixture queued_cancelled_while_active sends a user message, then an agent-queued message for the same thread, then cancels run 2. It expects the user message to be run 1 and the agent message to be run 2. The suite runs it on :memory:. These steps run it on a file database built by layerFromPath, the way the server opens its database.
- Check out
mainand runvp i. - From the repo root, apply this patch with
git apply. It replaces the default database layer inapps/server/src/orchestration-v2/testkit/ProviderReplayHarness.tswith a file database:
diff
| 1 | diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts |
| 2 | index 0342a3ccc9..b83702246c 100644 |
| 3 | --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts |
| 4 | +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts |
| 5 | @@ -1,3 +1,6 @@ |
| 6 | +import * as NodeFs from "node:fs"; |
| 7 | +import * as NodeOs from "node:os"; |
| 8 | +import * as NodePath from "node:path"; |
| 9 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; |
| 10 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 11 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 12 | @@ -289,7 +292,16 @@ export function layerWithRegistry<Error>( |
| 13 | : RuntimePolicy.layerWithOverride(scenario.runtimePolicyOverride).pipe( |
| 14 | Layer.provide(RuntimePolicy.layer), |
| 15 | ); |
| 16 | - const layerDatabase = options.databaseLayer ?? SqlitePersistence.layerMemory; |
| 17 | + const layerDatabase = |
| 18 | + options.databaseLayer ?? |
| 19 | + Layer.unwrap( |
| 20 | + Effect.sync(() => { |
| 21 | + const dir = NodeFs.mkdtempSync(NodePath.join(NodeOs.tmpdir(), "order-repro-")); |
| 22 | + return SqlitePersistence.layerFromPath(NodePath.join(dir, "state.sqlite")).pipe( |
| 23 | + Layer.provide(NodeServices.layer), |
| 24 | + ); |
| 25 | + }), |
| 26 | + ); |
| 27 | // One queue shared by the adapters, the orchestrator, and the worker, like |
| 28 | // runtimeLayer.ts; layer memoization keeps it a single instance. |
| 29 | const layerContinuationRequests = |
- In
apps/server, runvp test run src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts -t queued_cancelled_while_active.
Expected behavior
- The user message, sent first, becomes run 1. The agent message becomes run 2, and the fixture cancels it. The test passes, as it does on
:memory:.
Actual behavior
- With the file database, the test failed in 8 of 8 trials. In 5 trials the cancel arrived before the user's run existed, and the test failed in 0.2-0.4 s with
Run ...:ordinal:2 is not queued.In the other 3 it cancelled the user's run, and the test timed out after 120 s. With:memory:, the test passed in 8 of 8 trials. How the failures split between the two kinds varies from run to run. - The steps above show the failure but not which message got which run. A projection dump added on top of the patch showed it: in a separate 8 + 8 trials, the agent message became run 1 and the user message run 2 in 8 of 8 file-database trials, and the user message was run 1 in 8 of 8
:memory:trials. - Cause:
dispatchWithReceiptinOrchestrator.tsrunsappOwnedSubagentParentThreadIdfor amessage.dispatchfrom the user or from MCP before it takes the thread lock. That callsprojectionStore.getThread, which runs underNodeSqliteClient.readOnly.layerFromPathsetsreaderWorker: true, so on a file database the read goes to the reader worker and yields. A message that skips that read, such as an agent-queued one, takes the thread lock first. On:memory:there is no reader worker, so the read runs on the writer without yielding and the order holds. A counter on the reader route showed reader reads on every file-database trial and none on:memory:. - This looks like the ordering change mentioned in #19211 and #19226. The worker in #19211 made the
:memory:read async too, which exposed a race that file databases already have. - I have not worked out a fix. The lookup decides whether to take the parent thread's lock before the child's, so moving it inside the child lock would change the lock order.
- I have not measured how often this happens in a real install. It needs a user or MCP message and an agent message for the same thread within one reader round trip.
Impact
Minor bug or occasional failure
Version or commit
main @ 6c21798485
Environment
Linux (Fedora 44), Node 24.22.1
Logs or stack traces
shell
| 1 | file database (layerFromPath), 8 trials: failed 8/8 (5 "Run ...:ordinal:2 is not queued." in 0.2-0.4 s, 3 timeouts at 120 s) |
| 2 | :memory:, 8 trials: passed 8/8 |
| 3 | traced pass, 8 + 8 trials: run 1 = agent message 8/8 on file DB, user message 8/8 on :memory: |
Screenshots, recordings, or supporting files
No response
Workaround
None.
Repro and this text by Claude Opus 5.5 in T3 Code Nightly.
🤖 Generated with Claude Opus 5.5 in T3 Code Nightly