app-server: compaction completes server-side but `turn/completed` is never delivered to the JSON-RPC client — thread stays "Compacting…" yet remains healthy/resumable
Summary
When driving Codex purely over the codex app-server JSON-RPC transport (no TUI), a turn that triggers context compaction can leave the client stuck in a "Compacting…" state forever, even though the server-side thread is healthy: the compaction actually completed, the thread is still usable, and resuming it immediately recovers a fully working session. The missing piece is the completion notification — the client never receives the turn/completed (or an equivalent compaction-finished) event for that turn, so an external client has no signal that the turn is done.
This is distinct from the well-reported un-compactable context hangs (e.g. #24388) where compaction genuinely loops/deadlocks. Here compaction succeeds; only the notification is lost.
Environment
- Codex CLI 0.139.0 (Homebrew cask,
codex-aarch64-apple-darwin), macOS (Darwin arm64). - Transport:
codex -c shell_environment_policy.inherit=all app-server --listen stdio://, newline-delimited JSON-RPC 2.0. - Third-party client (not the TUI / Desktop / VS Code extension): a native app that owns the app-server lifecycle and consumes
turn/*+thread/*notifications directly.
Steps to reproduce
- Start a thread over app-server and drive several turns until the context approaches the auto-compaction threshold (high reasoning effort makes this easier to hit).
- Send a turn that crosses the threshold, so the server performs (remote) compaction mid-turn.
- Observe the notification stream for that turn.
Expected
- The client receives a terminal
turn/completed(or a documented compaction-completed → turn-completed) notification for every turn that the server actually finished, including turns where compaction ran.
Actual
- The turn completes server-side (the thread is idle and fully usable afterward), but no
turn/completednotification arrives for that turn. The client is left believing the turn — and the compaction — is still in progress indefinitely. - Recovery confirms the thread is healthy: tearing down and re-attaching with
thread/resume(same thread id) immediately yields a working session with the compacted history intact. So the state desync is purely on the notification channel, not the thread.
Why this matters for app-server clients
External clients have no TUI heuristic to fall back on; the notification stream is the source of truth. A silently dropped terminal event for a turn is unrecoverable without a timeout/poll workaround. This is the exact lifecycle-guarantee question raised in #20943 ("Clarify app-server turn lifecycle semantics for external clients — is turn/completed guaranteed across all terminal paths?").
Questions for maintainers
- Is
turn/completedguaranteed to be emitted for every terminal outcome of a turn, including turns that perform compaction? (If yes, this is a delivery bug; if no, app-server clients need a documented alternative completion signal.) - Could the WebSocket-reset / connection-cleanup behavior (e.g. #23954, "managed app-server daemon resets the WebSocket and drops clients") drop the completion notification specifically around the longer compaction window?
- Is there a request-scoped turn-state / sequence number an external client can use to detect a missed terminal event and reconcile?
Related
- #20943 — clarify app-server turn lifecycle semantics for external clients (the contract this violates)
- #14346 — "silent compaction that hangs, then self-resumes ~15 min later" (behaviorally adjacent, framed as TUI)
- #23954 — managed app-server resets WebSocket / drops clients (plausible drop mechanism)
- #20208, #21937, #9251 — other "completion/turn event never reaches the client" reports (different mechanisms)
- #24388 — un-compactable context deadlock (the other, already-reported failure mode; not this one)
Note on recent changes
0.140.0 landed #27996 ("Send request-scoped turn state over WebSocket") and #28002 ("Send turn state through compact requests"), which look directly adjacent. If those already fix this on 0.140, please confirm — happy to re-test against 0.140 and close.
10 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
I took a deeper look at this one against current
main.What I see in the app-server path:
EventMsg::TurnCompleteis handled inapp-server/src/bespoke_event_handling.rs; after aborting pending server requests and noting the thread watcher completion, it callshandle_turn_complete(...).handle_turn_complete(...)clears the per-turn summary and emitsServerNotification::TurnCompletedviaemit_turn_completed_with_status(...).EventMsg::TurnCompleteas terminal state for the active turn.I also ran the focused compaction test locally in WSL:
Result: passed (
1 passed; 0 failed). That test drives the app-server JSON-RPC flow, triggers remote auto-compaction on the third turn, waits for the matchingturn/completed, and verifies the compaction request includes the expected turn metadata.So the current happy-path live-connection case appears covered on
main. That makes the original 0.139.0 report look either fixed by the turn-state/compaction work that landed around 0.140, or dependent on a lifecycle edge case rather than the basic compaction completion path.The remaining suspicious gap I found is delivery across connection lifecycle changes.
ThreadScopedOutgoingMessageSender::send_server_notification(...)returns early when there are noconnection_ids. For in-process queues,TurnCompletedis explicitly classified as requiring delivery, but the thread-scoped sender still depends on a live connection id at send time. If the managed app-server reset / cleanup mentioned in #23954 drops the client connection during a long compaction, the server can finish the turn and keep the thread resumable while the original JSON-RPC client never observes the terminalturn/completed.A useful next regression test would simulate disconnect/reconnect or stale connection ids during the compaction window, then assert one of these is true:
turn/completedis replayed to the reconnected client, orthread/read/thread/listexposes enough terminal turn state for the client to reconcile instead of waiting forever.In short: I cannot reproduce the live-connection path on current
main; the higher-value repro now seems to be a connection-lifecycle test around long compaction, not another direct compaction happy-path test.Confirming your connection-lifecycle hypothesis from the client side. In CodeMantis we see exactly this: during a long compaction the thread stays healthy and resumable — a fresh TUI
/modelworks,thread/resumerecovers full context — but our original JSON-RPC client never receivesturn/completed, so the UI sits on "Compacting…" indefinitely. It's not the happy path; it's delivery when the connection churns underneath an in-flight turn, matching yoursend_server_notificationearly-return on missingconnection_ids.Of your two proposed assertions, (2)
thread/read/thread/listexposing terminal turn state is the higher-value one for clients — it lets us reconcile on a timeout without forcing a resume, which is what we do as a workaround today. (Re the duplicate flag for #27395: related but distinct — that's aturn/starttimeout, ours is compaction-specific and stays resumable. Same family of "server finishes, client left hanging," different trigger.)This issue maps closely to LS work on durable lifecycle events, subscriber cursors, terminal receipts, and deterministic reconciliation.
The central distinction is:
server-side operation completed
≠ completion event delivered
≠ client projection updated
≠ client knows the thread is idle
A terminal lifecycle transition should not exist only as a best-effort notification.
A possible authoritative turn state:
{
"schema_version": "turn-lifecycle-state/v0.1",
"thread_id": "thread-123",
"turn_id": "turn-044",
"trajectory_id": "project-alpha",
"state": "COMPLETED",
"terminal_sequence": 1842,
"completed_at": "2026-06-24T19:42:11Z",
"compaction": {
"performed": true,
"status": "COMPLETED",
"result_ref": "compaction-result-771"
}
}
The notification should then be treated as a projection of this durable state:
{
"sequence": 1842,
"event_id": "turn-044:completed",
"event_type": "TURN_COMPLETED",
"thread_id": "thread-123",
"turn_id": "turn-044",
"terminal_state_digest": "sha256:..."
}
A useful invariant would be:
«Every terminal turn state must be queryable and replayable even if its live notification was lost.»
Another invariant:
«A client must not depend on a single transient event to determine whether a thread is still active.»
A client cursor could track:
{
"client_id": "external-jsonrpc-client",
"thread_id": "thread-123",
"last_received_sequence": 1841,
"last_acknowledged_sequence": 1841,
"expected_terminal_turn": "turn-044"
}
If the client sees no terminal event within a bounded interval, it should be able to request reconciliation:
get_turn_state(thread_id, turn_id, after_sequence)
Possible response:
{
"thread_id": "thread-123",
"turn_id": "turn-044",
"authoritative_state": "COMPLETED",
"terminal_sequence": 1842,
"client_missing_sequences": [
1842
],
"replay_events": [
{
"event_type": "TURN_COMPLETED",
"sequence": 1842
}
]
}
This is similar to reliable subscription recovery:
live event stream
→ cursor
→ disconnect or dropped event
→ reconnect
→ replay from last acknowledged sequence
→ rebuild client projection
The client should not need to tear down the session and call "thread/resume" merely to discover that the previous turn already finished.
The compaction lifecycle could also be explicit:
TURN_RUNNING
→ COMPACTION_STARTED
→ COMPACTION_COMPLETED
→ TURN_COMPLETED
with each transition carrying a monotonic sequence number.
A terminal receipt might look like:
{
"schema_version": "turn-terminal-receipt/v0.1",
"thread_id": "thread-123",
"turn_id": "turn-044",
"terminal_state": "COMPLETED",
"sequence": 1842,
"result_digest": "sha256:...",
"delivery_status": {
"published": true,
"acknowledged_clients": [],
"replay_available": true
}
}
The important boundary is:
event published
≠ event acknowledged
A publisher should not assume the client received the terminal state merely because the notification write succeeded locally.
A deterministic conformance fixture could test:
Related LS work:
https://github.com/safal207/LS/pull/651
https://github.com/safal207/LS/issues/594
https://github.com/safal207/LS/issues/595
https://github.com/safal207/LS/issues/597
Would a small vendor-neutral "TurnTerminalReceipt", subscriber cursor, and dropped-notification replay fixture help make terminal-event delivery and client reconciliation machine-testable?
Author here — glad to see this framed as a reconciliation problem, since that's exactly where we landed as a client.
One caution on scope, though: for us the minimal fix is much smaller than a full durable-event/cursor/receipt system. What actually unblocks an external client is just two primitives:
turn/*(andthread/*) notifications, so a client can detect it missed a terminal event (the original question #3 here), andthread/readreturning the turn's terminal status, or aget_turn_state(thread_id, turn_id)— so we reconcile on a timeout without forcing athread/resume(which is the workaround we run today, and the anti-pattern you correctly call out).That's the higher-value of @MakenRosa's two assertions (terminal state exposed for reconciliation, vs. guaranteed replay), and it looks close to what #27996 ("Send request-scoped turn state over WebSocket") already started. The subscriber cursor, ack-tracking, and
TurnTerminalReceiptwith delivery status are reasonable as a longer-term reliability layer, but I'd keep this issue focused on the queryable-terminal-state + sequence-number increment so it stays shippable — the root cause @MakenRosa found (send_server_notificationearly-returning on missingconnection_idswhen the connection churns mid-compaction) is a concrete delivery bug that a pull-based reconcile path closes directly.A conformance fixture around disconnect/reconnect during the compaction window would be genuinely useful — happy to validate any candidate fix against CodeMantis on 0.140.
Thanks — agreed on the scope distinction.
The two primitives you identified look like the right Phase 1 contract:
That is enough for a client to:
I would treat subscriber cursors, acknowledgements, durable replay, and "TurnTerminalReceipt" as optional Phase 2 reliability layers rather than requirements for fixing this issue.
A minimal conformance fixture could therefore be:
One nuance on #27996: it appears to establish request-scoped model turn state across WebSocket and compact requests, which is adjacent and useful, but unless that state is exposed as authoritative terminal lifecycle state through "thread/read", it may not yet close the external-client reconciliation gap.
Happy to help reduce the earlier proposal into this smaller fixture and protocol contract.
+1 — the Phase 1 (sequence + queryable terminal state) / Phase 2 (cursors, acks, receipts, replay) split is the right line, and your 8-step fixture captures exactly the path we hit. The detect-gap → bounded-timeout → query-committed-state → converge-without-
thread/resumeloop is precisely the reconcile we want in place of our current resume-based workaround.Your #27996 nuance is the crux, and it's the one thing I'd want a maintainer to confirm: request-scoped turn state crossing the WebSocket/compact path is necessary but not sufficient — it only closes the external-client gap if that state is pullable as authoritative terminal lifecycle state (via
thread/reador a smallget_turn_state) after a reconnect. If it stays push-only, a client that missed the live event still has nowhere to query.Happy to pair on reducing this to the minimal fixture + contract — I can bring the client side (CodeMantis driving app-server on 0.140), so the fixture exercises a real external JSON-RPC consumer rather than only an in-process harness. At this point I think it mainly needs a maintainer to say whether the queryable-terminal-state surface belongs to the #27996 line of work or wants a separate method.
@HaraldSirius Yes — that’s exactly the boundary I’d want to lock in.
I agree the key acceptance point is not merely “request-scoped turn state survives the compact / reconnect path”, but “a client that missed the live terminal event can still pull the authoritative terminal lifecycle state afterward”.
So the minimal external-client contract I’d want a maintainer to confirm is:
turn/*/thread/*);thread/reador a smallget_turn_state);thread/resumeas a recovery primitive.That’s the crux for me as well: if terminal state remains push-only, the external JSON-RPC client still has no bounded recovery path after missing
turn/completed.And yes — using a real external consumer in the fixture would be ideal. The useful acceptance row feels like:
If maintainers are open to that shape, I think it would cleanly separate the Phase 1 reliability floor from the broader cursor/receipt/replay work.
Thanks for reporting this problem. Until recently, Codex used a separate endpoint and server-side logic for compaction. We recently switched to a more robust approach where the compaction logic is moved locally using the same endpoint as normal turns. This eliminates "remote compaction" errors and increases the stability and reliability of compaction operations. If you see further problems with compaction, please use
/feedbackand open a new bug report.@etraut-openai Thank you for the clear explanation and for closing the loop. Moving compaction onto the same local path and endpoint as normal turns directly addresses the split-brain behavior reported here: server-side completion without the corresponding client-visible terminal event.
The main invariant I’ll watch for in future regressions is that compaction and ordinary turns expose the same terminal-state semantics to the JSON-RPC client. If the UI becomes stuck again while the thread remains resumable, I’ll file a fresh report with
/feedbackdata as requested.