Fix goal-first live threads missing from thread/list (#28808)

Fixes #28263.

## Why

When a thread starts with `/goal`, the goal extension can update SQLite
goal state before the thread has any user-turn rollout items.
`thread/list` and `thread/search` rely on persisted listing metadata, so
a goal-first live thread could be absent from app-server listings after
restart even though the goal itself existed.

This regressed when goal handling moved out of core: the core path wrote
the goal update through the live thread rollout path, while the
extension-backed app-server path only updated goal state and emitted the
live notification.

## What

- Add `GoalSetOutcome::thread_goal_updated_item()` so the goal extension
owns the canonical `ThreadGoalUpdated` rollout item shape.
- Expose a narrow `CodexThread::append_rollout_items()` helper that
appends through the live thread and keeps derived SQLite metadata in
sync.
- When app-server sets a goal on an active live thread, persist the goal
update through that live-thread path.
- Add an app-server regression test that starts a live thread with
`thread/goal/set` and verifies it appears in state-DB-only
`thread/list`.

## Verification

- `env -u CODEX_SQLITE_HOME just test -p codex-app-server
goal_first_live_thread_appears_in_state_db_thread_list`
This commit is contained in:
Eric Traut
2026-06-18 10:50:15 -07:00
committed by GitHub
parent 9684ec25be
commit e8dd1b45cb
4 changed files with 129 additions and 0 deletions
@@ -135,6 +135,21 @@ impl ThreadGoalRequestProcessor {
.await
.map_err(goal_service_error)?;
let goal = ThreadGoal::from(outcome.goal.clone());
let persist_result = match self.thread_manager.get_thread(thread_id).await {
Ok(thread) => {
// Live goal-first threads can be listed before any user turn is written.
// Use the live path so JSONL and SQLite preview metadata stay in sync.
thread
.append_rollout_items(&[outcome.thread_goal_updated_item()])
.await
}
Err(_) => Ok(()),
};
if let Err(err) = persist_result {
warn!("failed to persist goal update for live thread {thread_id}: {err}");
}
self.outgoing
.send_response(
request_id.clone(),