Skip to content

Commit e7ae705

Browse files
timvwMrVampy
andcommitted
fix: detect Codex session via state_*.sqlite threads table
Codex >= ~0.118 moved session state from per-session rollout JSONL files to a SQLite database (~/.codex/state_*.sqlite). Long-lived sessions keep the same thread ID and just bump updated_at — no new rollout file is written, causing the rollout walker (now Method 4) to miss them. Add Method 3: query the threads table directly, filtering by cwd and preferring recently-updated unarchived threads. The DB file is found via python3 glob (no ls -t pipe), opened read-only to avoid contention with a running Codex writer. - Renumber rollout JSONL walker from Method 3 to Method 4 - Add unit tests: cwd match, cwd miss, archived exclusion, dedup across panes, and priority over rollout JSONL fallback Based on analysis of the Codex source (codex-rs/state/) confirming the threads table schema and update semantics. Co-authored-by: Thomas Andersen <MrVampy@users.noreply.github.com>
1 parent 74ccf85 commit e7ae705

2 files changed

Lines changed: 180 additions & 6 deletions

File tree

scripts/save-assistant-sessions.sh

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,90 @@ get_codex_session() {
177177
return
178178
fi
179179

180-
# Method 3: Codex rollout session files (newer Codex versions).
181-
# Newer releases persist session metadata under ~/.codex/sessions/*/*.jsonl
182-
# and include a session_meta record with both id and cwd.
180+
# Method 3: Codex thread state DB (Codex >= ~0.118 persist state in
181+
# SQLite: ~/.codex/state_*.sqlite, table `threads`, columns id/cwd/
182+
# updated_at/archived). This is the canonical current source — codex
183+
# writes a `threads` row per session and bumps `updated_at` on every
184+
# user turn. A long-lived session that started days ago keeps its
185+
# same `id` in this table even though no new rollout JSONL is ever
186+
# written, which is exactly the case Method 4 misses.
187+
#
188+
# Strategy: among threads matching our process's cwd that are unarchived
189+
# and have been updated during this process's lifetime, pick the most
190+
# recently updated one that isn't already assigned to another pane.
191+
#
192+
# The DB file is versioned (state_5.sqlite, bumping on schema changes).
193+
# We glob for state_*.sqlite inside python3 (avoids `ls -t` pipe and
194+
# handles spaces in paths cleanly) and pick the newest by mtime.
195+
if [ -n "$cwd" ] && command -v python3 >/dev/null 2>&1; then
196+
local etimes
197+
etimes=$(ps -o etimes= -p "$child_pid" 2>/dev/null | tr -d ' ' || true)
198+
sid=$(
199+
USED_CODEX_SESSION_IDS="$USED_CODEX_SESSION_IDS" python3 - "$HOME/.codex" "$cwd" "$etimes" <<'PY'
200+
import glob, os, sqlite3, sys, time
201+
202+
codex_home = sys.argv[1]
203+
cwd = sys.argv[2]
204+
etimes_raw = sys.argv[3].strip()
205+
used = {sid for sid in os.environ.get("USED_CODEX_SESSION_IDS", "").split("\t") if sid}
206+
207+
# Find the newest state_*.sqlite by mtime.
208+
dbs = sorted(glob.glob(os.path.join(codex_home, "state_*.sqlite")),
209+
key=os.path.getmtime, reverse=True)
210+
if not dbs:
211+
sys.exit(0)
212+
213+
process_start = None
214+
if etimes_raw.isdigit():
215+
process_start = time.time() - int(etimes_raw)
216+
217+
# Open read-only so we never conflict with a running codex writer.
218+
try:
219+
con = sqlite3.connect(f"file:{dbs[0]}?mode=ro", uri=True)
220+
except sqlite3.Error:
221+
sys.exit(0)
222+
223+
try:
224+
cur = con.cursor()
225+
cur.execute(
226+
"SELECT id, updated_at FROM threads "
227+
"WHERE cwd = ? AND archived = 0 "
228+
"ORDER BY updated_at DESC",
229+
(cwd,),
230+
)
231+
rows = cur.fetchall()
232+
finally:
233+
con.close()
234+
235+
# Prefer threads whose last update happened after the process started
236+
# (rules out stale threads in the same cwd). Fall back to most-recent
237+
# overall if nothing qualifies — covers the edge case where a session
238+
# was spawned but hasn't had any user turns yet.
239+
def pick(rows, require_after_start):
240+
for sid, updated_at in rows:
241+
if sid in used:
242+
continue
243+
if require_after_start and process_start is not None and updated_at < process_start:
244+
continue
245+
return sid
246+
return None
247+
248+
sid = pick(rows, require_after_start=True) or pick(rows, require_after_start=False)
249+
if sid:
250+
print(sid)
251+
PY
252+
)
253+
if [ -n "$sid" ]; then
254+
echo "$sid"
255+
return
256+
fi
257+
fi
258+
259+
# Method 4: Codex rollout session files (Codex ~0.100-0.117 wrote
260+
# these; newer versions have moved to SQLite, see Method 3).
261+
# Releases in that window persisted session metadata under
262+
# ~/.codex/sessions/*/*.jsonl and included a session_meta record
263+
# with both id and cwd.
183264
# We rank candidates by:
184265
# - matching cwd
185266
# - preferring session IDs not already assigned during this save

test/run-tests.sh

Lines changed: 96 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,9 +1002,102 @@ assert_eq "Codex resume extraction" "ses_codex_789" "$(get_codex_session 99999 "
10021002
assert_eq "Codex resume with path" "ses_codex_789" "$(get_codex_session 99999 "/usr/bin/codex resume ses_codex_789")"
10031003
assert_eq "Codex bare (no resume)" "" "$(get_codex_session 99999 "codex")"
10041004
1005-
# --- Codex: rollout session files (Method 3) ---
1006-
# Newer Codex versions write session metadata to ~/.codex/sessions/*/*.jsonl
1007-
# instead of session-tags.jsonl. Test that get_codex_session can find them.
1005+
# --- Codex: state_*.sqlite thread DB (Method 3) ---
1006+
# Codex >= ~0.118 persists thread state in SQLite. The save script queries
1007+
# the threads table by cwd, preferring recently-updated unarchived threads.
1008+
1009+
echo ""
1010+
echo "=== Codex state DB: thread lookup via state_*.sqlite ==="
1011+
echo ""
1012+
1013+
STATEDB_TEST_DIR=$(mktemp -d)
1014+
mkdir -p "$STATEDB_TEST_DIR/.codex"
1015+
1016+
# Create a test state DB with the threads table
1017+
python3 - "$STATEDB_TEST_DIR/.codex/state_5.sqlite" <<'DBSETUP'
1018+
import sqlite3, sys, time
1019+
db = sys.argv[1]
1020+
conn = sqlite3.connect(db)
1021+
conn.execute('''CREATE TABLE threads (
1022+
id TEXT PRIMARY KEY,
1023+
rollout_path TEXT NOT NULL,
1024+
created_at INTEGER NOT NULL,
1025+
updated_at INTEGER NOT NULL,
1026+
source TEXT NOT NULL,
1027+
model_provider TEXT NOT NULL,
1028+
cwd TEXT NOT NULL,
1029+
title TEXT NOT NULL,
1030+
sandbox_policy TEXT NOT NULL,
1031+
approval_mode TEXT NOT NULL,
1032+
tokens_used INTEGER NOT NULL DEFAULT 0,
1033+
has_user_event INTEGER NOT NULL DEFAULT 0,
1034+
archived INTEGER NOT NULL DEFAULT 0,
1035+
archived_at INTEGER
1036+
)''')
1037+
now = int(time.time())
1038+
# Active thread matching test cwd — updated recently
1039+
conn.execute('''INSERT INTO threads (id, rollout_path, created_at, updated_at, source,
1040+
model_provider, cwd, title, sandbox_policy, approval_mode)
1041+
VALUES (?, '', ?, ?, 'cli', 'openai', '/tmp/statedb-project', 'active', 'relaxed', 'auto')''',
1042+
('ses_statedb_active', now - 3600, now - 10))
1043+
# Older thread same cwd — should lose to the active one
1044+
conn.execute('''INSERT INTO threads (id, rollout_path, created_at, updated_at, source,
1045+
model_provider, cwd, title, sandbox_policy, approval_mode)
1046+
VALUES (?, '', ?, ?, 'cli', 'openai', '/tmp/statedb-project', 'old', 'relaxed', 'auto')''',
1047+
('ses_statedb_old', now - 86400, now - 86400))
1048+
# Archived thread same cwd — should be excluded
1049+
conn.execute('''INSERT INTO threads (id, rollout_path, created_at, updated_at, source,
1050+
model_provider, cwd, title, sandbox_policy, approval_mode, archived, archived_at)
1051+
VALUES (?, '', ?, ?, 'cli', 'openai', '/tmp/statedb-project', 'archived', 'relaxed', 'auto', 1, ?)''',
1052+
('ses_statedb_archived', now - 7200, now - 5, now - 5))
1053+
# Thread in different cwd — should not match
1054+
conn.execute('''INSERT INTO threads (id, rollout_path, created_at, updated_at, source,
1055+
model_provider, cwd, title, sandbox_policy, approval_mode)
1056+
VALUES (?, '', ?, ?, 'cli', 'openai', '/tmp/other-project', 'other', 'relaxed', 'auto')''',
1057+
('ses_statedb_other', now - 100, now - 1))
1058+
conn.commit()
1059+
conn.close()
1060+
DBSETUP
1061+
1062+
ORIG_HOME="$HOME"
1063+
HOME="$STATEDB_TEST_DIR"
1064+
1065+
# Should find the most recently updated active thread for the matching cwd
1066+
statedb_sid=$(get_codex_session $$ "codex" "/tmp/statedb-project")
1067+
assert_eq "Codex state DB: finds active thread by cwd" "ses_statedb_active" "$statedb_sid"
1068+
1069+
# Should NOT match a different cwd
1070+
statedb_miss=$(get_codex_session $$ "codex" "/tmp/nonexistent")
1071+
assert_eq "Codex state DB: no match for different cwd" "" "$statedb_miss"
1072+
1073+
# Dedup: after claiming ses_statedb_active, next call should get ses_statedb_old
1074+
USED_CODEX_SESSION_IDS=""
1075+
statedb_first=$(get_codex_session $$ "codex" "/tmp/statedb-project")
1076+
register_codex_session_id "$statedb_first"
1077+
statedb_second=$(get_codex_session $$ "codex" "/tmp/statedb-project")
1078+
1079+
if [ -n "$statedb_first" ] && [ -n "$statedb_second" ] && [ "$statedb_first" != "$statedb_second" ]; then
1080+
pass "Codex state DB dedup: two calls get distinct sessions ($statedb_first vs $statedb_second)"
1081+
else
1082+
fail "Codex state DB dedup: expected distinct sessions, got '$statedb_first' and '$statedb_second'"
1083+
fi
1084+
USED_CODEX_SESSION_IDS=""
1085+
1086+
# Should prefer state DB (Method 3) over rollout JSONL (Method 4) when both exist
1087+
mkdir -p "$STATEDB_TEST_DIR/.codex/sessions/2026/04/23"
1088+
cat >"$STATEDB_TEST_DIR/.codex/sessions/2026/04/23/rollout-statedb-test.jsonl" <<'ROLLOUT'
1089+
{"timestamp":"2026-04-23T10:00:00.000Z","type":"session_meta","payload":{"id":"ses_rollout_loser","timestamp":"2026-04-23T10:00:00.000Z","cwd":"/tmp/statedb-project","originator":"codex_cli_rs","cli_version":"0.116.0"}}
1090+
ROLLOUT
1091+
1092+
statedb_priority=$(get_codex_session $$ "codex" "/tmp/statedb-project")
1093+
assert_eq "Codex state DB takes priority over rollout JSONL" "ses_statedb_active" "$statedb_priority"
1094+
1095+
HOME="$ORIG_HOME"
1096+
rm -rf "$STATEDB_TEST_DIR"
1097+
1098+
# --- Codex: rollout session files (Method 4) ---
1099+
# Codex ~0.100-0.117 wrote session metadata to ~/.codex/sessions/*/*.jsonl.
1100+
# Newer versions use SQLite (Method 3). Test the JSONL fallback.
10081101
10091102
ROLLOUT_TEST_DIR=$(mktemp -d)
10101103
mkdir -p "$ROLLOUT_TEST_DIR/.codex/sessions/2026/03/24"

0 commit comments

Comments
 (0)