Native goal is silently deleted from thread_goals on thread resume / after Voice Live ends
Bug description
A native goal that is created and correctly persisted gets silently deleted when the thread is resumed/rehydrated. After resuming a thread (or after a Voice Live session ends, which triggers the same rehydration path via transcript_tail_flush), get_goal returns null and update_goal(status="complete") fails with:
cannot update goal because this thread has no goal
The app-server emits thread/goal/cleared during the resume, and the row is physically removed from the thread_goals table in ~/.codex/goals_1.sqlite.
Reproduction steps (headless, no voice needed)
- Create a thread and a goal:
````
codex exec "Call create_goal with objective 'canary goal', then call get_goal and print the raw result."
get_goalreturnsstatus: "active".- Confirmed in
goals_1.sqlite: a row for the newthread_idexists inthread_goals(statusactive).
- Resume the same thread:
````
codex exec resume <session-id> "Call get_goal and print the raw result."
- While the resumed session is running (before any model turn even completes), the runtime emits:
```
app-server event: thread/goal/cleared targeted_connections=0
thread_id
and the row for that is **deleted** from thread_goals`. The six pre-existing rows for older threads were untouched.
get_goalon the resumed thread returnsnull.
Observed timeline (2026-08-16, local time)
- 10:51:34 — goal row present in
thread_goals(snapshot of the sqlite db). - 10:52 —
codex exec resumestarted. - 11:03:37 and 11:08:37 — two
thread/goal/clearedevents in the runtime logs during the resume. - 11:14 — goal row gone from
thread_goals(second snapshot).
Voice Live variant (original sighting)
When a Voice (realtime) conversation ends, the injected transcript_tail_flush message ("Realtime conversation ended...") is followed by the same goal clearing: goal active before the voice session, get_goal = null right after. This makes goal mode unusable across any voice session boundary.
Additional inconsistency
On the affected thread, create_goal is refused with:
cannot create a new goal because this thread has an unfinished goal; complete the existing goal first
while get_goal returns null — so the "unfinished goal" check used by create_goal reads different state than get_goal/update_goal.
Environment
- codex-cli 0.145.0 (darwin arm64)
- VS Code extension + CLI (
codex exec) - macOS
Expected behavior
Resuming a thread (or ending a Voice Live session) should preserve the thread's active goal, or at minimum surface why it was cleared. Silent deletion makes native goals unusable for any multi-session or voice-interleaved workflow.
2 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Partial analysis from reading the goal persistence layer on
main@ 1f41cc5d92 — I didn't find the final culprit, but the code rules out several suspects and turns your "additional inconsistency" into a strong constraint on where the bug can live.1. Your create/get inconsistency is impossible at the SQL layer — which localizes it to session wiring.
get_goalis a plainSELECT … WHERE thread_id = ?with no filters (state/src/runtime/goals.rs#L41-L66).create_goal's "unfinished goal" error is produced when the upsert'sON CONFLICT(thread_id) … WHERE thread_goals.status = 'complete'clause declines to replace an existing non-complete row for that same thread_id (goals.rs#L213-L246, surfaced atext/goal/src/tool.rs#L207-L213). Against one database, "get → null" and "create → conflict" for the same key cannot both be true. So on the affected resumed thread, the two tool calls must be executing with different thread identities or different state-DB handles — i.e., the rehydration path wires at least one goal tool instance to a stale thread id (or a staleStateRuntime). That's a much smaller search space than "goals are flaky."2. What can delete the row.
thread_goalsrows are removed on exactly two kinds of path:thread/goal/deleteRPC and TUI action, both user-driven (ext/goal/src/api.rs#L288-L329);delete_threads_strictremoves the goal row along with queue/memories/logs whenever a thread row is deleted (state/src/runtime/threads.rs#L1090-L1107).Notably, the resume-restore hook itself only reads:
restore_after_resumefetches the goal and updates in-memory accounting, never the DB (ext/goal/src/runtime.rs#L338-L360). And thethread/goal/clearedyou captured is emitted by the resume snapshot after a DB read finds nothing (app-server/src/request_processors/thread_lifecycle.rs#L788-L820) — it reports the loss, it doesn't cause it. Combined with your observation that the row physically disappears during resume, the cascade path is the prime suspect: something in exec-resume's thread-row reconciliation (delete + re-insert of the thread row, e.g. id/session migration or rollout↔state-db reconciliation) would take the goal with it silently, and re-inserting the thread row does not restore the goal.3. A discriminating experiment (cheap, no voice needed): snapshot
goals_1.sqliteand the threads table before and aftercodex exec resume. If the thread row'srowid/created_atchanges across resume (same thread_id, fresh row), the cascade theory is confirmed and the fix is to make that reconciliation preserve (or re-attach) dependent rows — goals, queue, memories — rather than cascade-deleting them. If the thread row is untouched, the remaining suspects shrink to the explicit-clear RPC being invoked by the resume path itself, which would show up as athread/goal/deleterequest in verbose app-server logs.Happy to be corrected by whoever owns this area, but at minimum: (1) means the create/get divergence is a resume-wiring bug independent of the deletion, and both are worth regression tests — "goal survives
codex exec resume" and "goal tools agree on thread identity after rehydration".