@@ -110,19 +110,29 @@ fn remove_dirs_matching_name(base: &Path, name: &str) -> Result<(), io::Error> {
110110// Codex
111111// ---------------------------------------------------------------------------
112112
113- /// Codex session files live under `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`.
114- /// We find the file whose first line's `payload.id` matches and delete it.
115- /// We also rewrite `~/.codex/history.jsonl` excluding matching `session_id` entries.
113+ /// Codex session data lives in three places (any combination may be present
114+ /// depending on Codex CLI version):
115+ /// * `state_*.sqlite` `threads` row (primary source for current Codex CLI),
116+ /// * `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` rollout file,
117+ /// * `~/.codex/history.jsonl` per-prompt summary entries.
118+ ///
119+ /// We delete from all three so the session does not reappear after the next
120+ /// `agf` scan via the SQLite path.
116121fn delete_codex_session ( session : & Session ) -> Result < ( ) , io:: Error > {
117122 let codex_dir = config:: codex_dir ( ) . map_err ( io:: Error :: other) ?;
118123
119- // 1. Find and delete the session rollout file
124+ // 1. Delete the SQLite row(s). Codex may have multiple `state_*.sqlite`
125+ // files (e.g. across CLI upgrades); remove the row from every one
126+ // that contains it so the session cannot be revived from a stale db.
127+ delete_codex_sqlite_rows ( & codex_dir, & session. session_id ) ?;
128+
129+ // 2. Find and delete the session rollout file
120130 let sessions_dir = codex_dir. join ( "sessions" ) ;
121131 if sessions_dir. exists ( ) {
122132 delete_codex_session_file ( & sessions_dir, & session. session_id ) ?;
123133 }
124134
125- // 2 . Rewrite history.jsonl excluding lines with matching session_id
135+ // 3 . Rewrite history.jsonl excluding lines with matching session_id
126136 let history_path = codex_dir. join ( "history.jsonl" ) ;
127137 if history_path. exists ( ) {
128138 rewrite_jsonl_excluding ( & history_path, "session_id" , & session. session_id ) ?;
@@ -131,6 +141,36 @@ fn delete_codex_session(session: &Session) -> Result<(), io::Error> {
131141 Ok ( ( ) )
132142}
133143
144+ /// Remove the `threads` row matching `session_id` from every
145+ /// `state_*.sqlite` in `codex_dir`. Missing tables / open errors on a single
146+ /// file are swallowed so one corrupt or older-schema db cannot block the
147+ /// delete on the others.
148+ fn delete_codex_sqlite_rows ( codex_dir : & Path , session_id : & str ) -> Result < ( ) , io:: Error > {
149+ let entries = match fs:: read_dir ( codex_dir) {
150+ Ok ( e) => e,
151+ Err ( _) => return Ok ( ( ) ) ,
152+ } ;
153+ for entry in entries. filter_map ( |e| e. ok ( ) ) {
154+ let path = entry. path ( ) ;
155+ let is_state_db = path
156+ . file_name ( )
157+ . and_then ( |n| n. to_str ( ) )
158+ . map ( |n| n. starts_with ( "state_" ) && n. ends_with ( ".sqlite" ) )
159+ . unwrap_or ( false ) ;
160+ if !is_state_db {
161+ continue ;
162+ }
163+ let conn = match rusqlite:: Connection :: open ( & path) {
164+ Ok ( c) => c,
165+ Err ( _) => continue ,
166+ } ;
167+ // `threads` is the only table the Codex scanner reads from. Older
168+ // Codex CLI versions may not have this table — ignore the error.
169+ let _ = conn. execute ( "DELETE FROM threads WHERE id = ?1" , [ session_id] ) ;
170+ }
171+ Ok ( ( ) )
172+ }
173+
134174/// Find and delete the Codex rollout JSONL file matching the given session ID.
135175fn delete_codex_session_file ( sessions_dir : & Path , session_id : & str ) -> Result < ( ) , io:: Error > {
136176 for entry in WalkDir :: new ( sessions_dir)
@@ -355,3 +395,95 @@ fn delete_gemini_session(session: &Session) -> Result<(), io::Error> {
355395
356396 Ok ( ( ) )
357397}
398+
399+ #[ cfg( test) ]
400+ mod tests {
401+ use super :: * ;
402+
403+ fn make_codex_dir ( name : & str ) -> std:: path:: PathBuf {
404+ let dir = std:: env:: temp_dir ( ) . join ( name) ;
405+ let _ = fs:: remove_dir_all ( & dir) ;
406+ fs:: create_dir_all ( & dir) . unwrap ( ) ;
407+ dir
408+ }
409+
410+ /// Create a minimal `state_*.sqlite` mirroring the Codex schema fields
411+ /// the scanner reads, then seed two `threads` rows.
412+ fn seed_state_db ( path : & Path , ids : & [ & str ] ) {
413+ let conn = rusqlite:: Connection :: open ( path) . unwrap ( ) ;
414+ conn. execute_batch (
415+ "CREATE TABLE threads (
416+ id TEXT PRIMARY KEY,
417+ cwd TEXT NOT NULL,
418+ title TEXT NOT NULL DEFAULT '',
419+ updated_at INTEGER NOT NULL DEFAULT 0,
420+ archived INTEGER NOT NULL DEFAULT 0,
421+ git_branch TEXT,
422+ first_user_message TEXT NOT NULL DEFAULT ''
423+ );" ,
424+ )
425+ . unwrap ( ) ;
426+ for id in ids {
427+ conn. execute ( "INSERT INTO threads (id, cwd) VALUES (?1, '/tmp/x')" , [ id] )
428+ . unwrap ( ) ;
429+ }
430+ }
431+
432+ fn count_thread ( path : & Path , id : & str ) -> i64 {
433+ let conn = rusqlite:: Connection :: open ( path) . unwrap ( ) ;
434+ conn. query_row ( "SELECT COUNT(*) FROM threads WHERE id = ?1" , [ id] , |row| {
435+ row. get ( 0 )
436+ } )
437+ . unwrap ( )
438+ }
439+
440+ #[ test]
441+ fn delete_codex_sqlite_rows_removes_target_only ( ) {
442+ let dir = make_codex_dir ( "agf-test-codex-delete-target" ) ;
443+ let db = dir. join ( "state_5.sqlite" ) ;
444+ seed_state_db ( & db, & [ "target-id" , "keep-id" ] ) ;
445+
446+ delete_codex_sqlite_rows ( & dir, "target-id" ) . unwrap ( ) ;
447+
448+ assert_eq ! ( count_thread( & db, "target-id" ) , 0 ) ;
449+ assert_eq ! ( count_thread( & db, "keep-id" ) , 1 ) ;
450+ }
451+
452+ #[ test]
453+ fn delete_codex_sqlite_rows_walks_every_state_db ( ) {
454+ let dir = make_codex_dir ( "agf-test-codex-delete-multi" ) ;
455+ let db4 = dir. join ( "state_4.sqlite" ) ;
456+ let db5 = dir. join ( "state_5.sqlite" ) ;
457+ seed_state_db ( & db4, & [ "dup-id" ] ) ;
458+ seed_state_db ( & db5, & [ "dup-id" ] ) ;
459+
460+ delete_codex_sqlite_rows ( & dir, "dup-id" ) . unwrap ( ) ;
461+
462+ assert_eq ! ( count_thread( & db4, "dup-id" ) , 0 ) ;
463+ assert_eq ! ( count_thread( & db5, "dup-id" ) , 0 ) ;
464+ }
465+
466+ #[ test]
467+ fn delete_codex_sqlite_rows_ignores_non_state_files ( ) {
468+ let dir = make_codex_dir ( "agf-test-codex-delete-ignore" ) ;
469+ // A non-matching file must not crash the walk.
470+ fs:: write ( dir. join ( "history.jsonl" ) , b"" ) . unwrap ( ) ;
471+ let db = dir. join ( "state_1.sqlite" ) ;
472+ seed_state_db ( & db, & [ "a" ] ) ;
473+
474+ delete_codex_sqlite_rows ( & dir, "a" ) . unwrap ( ) ;
475+ assert_eq ! ( count_thread( & db, "a" ) , 0 ) ;
476+ }
477+
478+ #[ test]
479+ fn delete_codex_sqlite_rows_tolerates_missing_threads_table ( ) {
480+ let dir = make_codex_dir ( "agf-test-codex-delete-missing-table" ) ;
481+ let db = dir. join ( "state_0.sqlite" ) ;
482+ // Empty db (no `threads` table) — Codex versions without the table
483+ // must not abort the delete.
484+ rusqlite:: Connection :: open ( & db) . unwrap ( ) ;
485+
486+ // Should swallow the "no such table: threads" error.
487+ delete_codex_sqlite_rows ( & dir, "anything" ) . unwrap ( ) ;
488+ }
489+ }
0 commit comments