thread/unarchive fails for an on-disk archived rollout when a paginated SQLite row has a stale path
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.5k
- PR merge metrics
- PR metrics pending
Description
What issue are you seeing?
thread/unarchive fails with
JSON-RPC error -32600: no archived rollout found for thread id <thread-id>
while the archived rollout file is present in <codex_home>/archived_sessions/, and the preceding
thread/archive returned success. In this state the thread cannot be unarchived through the API.
The failure is intermittent, but we captured the filesystem and SQLite state right after a failed
thread/unarchive. That snapshot exposes a resolver path that appears to explain it.
Captured state (codex-cli 0.155.1)
{
"sessions": [],
"archived_sessions": ["rollout-2026-09-20T05-22-48-<thread-id>.jsonl"],
"threads_row": {
"id": "<thread-id>",
"rollout_path": "<codex_home>/sessions/2026/09/20/rollout-2026-09-20T05-22-48-<thread-id>.jsonl",
"archived": 0,
"archived_at": null,
"history_mode": "paginated"
}
}
The file move succeeded; the SQLite threads row still describes the pre-archive state. The archived
file name embeds the same thread id as the request, and the recorded path differs from it only by
directory. We did not capture the file's session meta line, so we cannot confirm rollout identity
from file contents. The failing sequence never calls thread/revert.
Resolver analysis
thread_rollout_resolver.rs#L107-L109:
when SQLite has a row whose rollout_path no longer exists on disk and history_mode == Paginated,
resolve() returns Ok(None) immediately, so the archived-directory fallback at
#L134-L146 is never
reached even though the caller passed LookupScope::IncludeArchived.
unarchive_thread.rs#L28-L35 then
reports the error above.
The short-circuit is deliberate, and we are not suggesting the constraint behind it be dropped: the
doc comment explains that after thread/revert a scan by thread id could find an older immutable
rollout, so SQLite's selected path is authoritative. That concern does not disappear when the
recorded path is missing — a recovery path still has to preserve which rollout is selected.
The captured state is consistent with the selected rollout having moved into archived_sessions/
while SQLite still points at its former location: the archived file has exactly the basename
recorded in SQLite, and the failing sequence never calls thread/revert. Recovering that same
rollout need not involve a broad scan by thread id. Archive moves sessions/<y>/<m>/<d>/<name> to
archived_sessions/<name>
(archive_thread.rs#L103) and
unarchive rebuilds the dated path from that same file name
(unarchive_thread.rs#L55-L75),
so the archived counterpart of a recorded sessions path is derivable from its basename. Checking
that single location may be narrow enough to stay inside the thread/revert constraint. We have not
implemented or tested this, so we offer it only as a possible direction.
Two observations about current behavior:
- Retrying
thread/unarchiveagainst the same unchanged state follows the same failing lookup
and does not repair the stale row. The filesystem fallback skipped here can invoke the existing
read-repair (read_repair_rollout_path), but this
failing path never reaches it. - Recovering the relocated selected rollout would address this lookup failure regardless of how
the row became stale, so the stale-row producer can be investigated separately.
Possible contributor: mark_archived returns Ok(()) when no row exists
This is a separate source-level observation, not a confirmed explanation for the stale row above.
let Some(mut metadata) = self.get_thread(thread_id).await? else {
return Ok(());
};
Is that no-op intentional? If it is, archive_threads cannot distinguish "no row yet" from "archive
recorded": it treats a mark_archived error as fatal and rolls the file moves back
(archive_thread.rs#L117-L133),
but that path is unreachable for this case.
We could not tell from the source whether the row we captured was (a) absent when mark_archived
ran, so it no-opped and a later write inserted it with the pre-archive path, or (b) present and
correctly updated, then overwritten by a later asynchronous metadata write. The captured state fits
both.
What steps can reproduce the bug?
The failing operations, per thread, over codex app-server JSON-RPC:
thread/start- run one turn to completion
thread/archive— returns successthread/unarchive— fails as above
These are the operations performed by
sdk/python/tests/test_app_server_lifecycle.py::test_archive_unarchive_round_trip_uses_materialized_rollout
in this repository. That test drives codex app-server over stdio against a mock Responses endpoint
configured through model_providers, so no external network access or real model inference is
required, and it uses a fresh CODEX_HOME per run. Its harness resolves the binary from
CODEX_EXEC_PATH, then from codex-rs/target/debug/codex.
We have observed the failure only during repeated runs of the whole Python SDK suite (pytest over
sdk/python/tests, sequential, single process). The isolated four-step sequence produced no
failures in 600 iterations on macOS and 60 on the same Linux CI runners. We do not yet know whether
full-suite execution is necessary to trigger the issue or simply increases its likelihood.
Observed frequency:
- 2 failures across 60 CI jobs that each ran the suite once (one on codex-cli 0.154.0, one on 0.155.1).
- 1 failure across 104 additional suite runs during a dedicated reproduction hunt on 4-vCPU Ubuntu
24.04 runners.
These are suite/job-level counts, not a measured per-operation failure probability. Most runs pass;
repeated suite execution was needed to capture the failures.
One practical note if you try to capture the state yourself: AppServerHarness.__exit__ removes its
CODEX_HOME during teardown, which destroys the evidence. The snapshot above was only obtainable
after changing teardown to keep the directory on the failing path.
What is the expected behavior?
thread/unarchive should recover an archived rollout when only its recorded location is stale,
without changing which rollout is selected. A stale index entry should not make an existing archived
rollout unreachable through the API.
Narrowly: when the SQLite-selected rollout is missing at its recorded path but the same rollout
exists in archived_sessions/, an IncludeArchived lookup should resolve it while preserving the
selected-rollout semantics thread/revert depends on.
Separately, it would be useful to clarify whether mark_archived returning Ok(()) for a missing
row is intentional, and how callers are expected to keep archive state consistent in that case.
Suggested regression coverage, which does not require chasing the intermittent failure:
- create a valid rollout in
archived_sessions/; - insert a
paginatedthreadsrow pointing at its formersessions/<y>/<m>/<d>/path, with
archived = 0andarchived_at = NULL; - assert that an
IncludeArchivedlookup resolves the relocated selected rollout and that
thread/unarchivesucceeds; - add a
thread/revertcase with several rollouts for the same thread, asserting that recovery
never selects an older, non-selected rollout.
Additional information
- Versions: reproduced on
codex-cli0.155.1, also observed on 0.154.0. All occurrences were on
Linux x86_64 (Ubuntu 24.04) CI runners; our macOS
attempts covered only the isolated four-step sequence, so we cannot say it is Linux-specific. - All code references are pinned to
rust-v0.155.1(be2951ea34f0d295ed0becf97079f92fa5f6950e). - Possibly related: #33860 looks like the mirror image —
thread/unarchivemoves the rollout and
updates SQLite, then fails with-32603 failed to read unarchived threadwith the storage
mutation already applied. Different error and code path, but also an archive-state consistency
problem between the rollout files and the SQLite row. - No PII: paths come from an ephemeral CI temp directory and thread ids are randomly generated; both
are redacted above anyway.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in codex-rs/thread-store/src/local/thread_rollout_resolver.rs and unarchive_thread.rs, then run sdk/python/tests/test_app_server_lifecycle.py::test_archive_unarchive_round_trip_uses_materialized_rollout. Trace IncludeArchived resolution and the selected-rollout constraint used by thread/revert. Done means a stale recorded path can recover its archived counterpart, thread/unarchive succeeds, and regression coverage preserves selected-rollout semantics.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, sqlite
- Domain
- api, backend, databases
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100