Skip to content
676 changes: 675 additions & 1 deletion crates/freshell-server/src/existence.rs

Large diffs are not rendered by default.

12 changes: 11 additions & 1 deletion crates/freshell-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,17 @@ async fn main() -> ExitCode {
// keeps the pure index answer — identical to pre-fix behavior.
.with_claude_transcript_locator(std::sync::Arc::new(|session_id: &str| {
freshell_freshagent::locate_transcript(session_id)
})),
}))
// Opencode rebind fix: the SAME by-id DB truth the attach arm
// trusts (`opencode --session <id>` resolves children and
// directory-less roots the root-filtered listing hides), so
// reconcile and attach can never disagree about whether an
// opencode session exists. Points at the SAME data home the
// OpencodeSource above uses. Unreadable DB => Unknown
// (bounded deferral), never a false dead_session.
.with_opencode_session_locator(existence::opencode_db_locator(
freshell_sessions::parse::default_opencode_data_home(),
)),
),
None => std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()),
},
Expand Down
4 changes: 2 additions & 2 deletions crates/freshell-sessions/src/parse/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ pub mod opencode;
pub use claude::{parse_session_content, ParseSessionOptions};
pub use codex::parse_codex_session_content;
pub use opencode::{
default_opencode_data_home, run_opencode_listing_query, OpencodeDegrade, OpencodeListing,
OpencodeListingResult, OpencodeProvider, OpencodeReadError, OpencodeSession,
default_opencode_data_home, run_opencode_listing_query, session_exists_by_id, OpencodeDegrade,
OpencodeListing, OpencodeListingResult, OpencodeProvider, OpencodeReadError, OpencodeSession,
OpencodeSessionRow, THREE_VIEWS_MARKER_SQL_PATTERN,
};
55 changes: 55 additions & 0 deletions crates/freshell-sessions/src/parse/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,61 @@ impl OpencodeProvider {
}
}

/// Busy timeout for the existence probe's by-id lookup. Deliberately much
/// shorter than `OPENCODE_DB_BUSY_TIMEOUT_MS` (5000ms): `exists()` runs
/// synchronously on the reconcile path, once per pane — N panes x 5s of
/// WAL lock contention would stall every restart. A still-locked DB is a
/// transient read failure (`Err` => the probe answers Unknown and
/// reconcile's bounded deferral retries), not evidence of absence.
const EXISTENCE_BY_ID_BUSY_TIMEOUT_MS: u64 = 250;

/// Existence-probe by-id lookup: does `<data_home>/opencode.db` hold a
/// `session` row with this id?
///
/// Deliberately NO `parent_id` filter — the attach arm
/// (`opencode --session <id>` -> session.get by id) resolves CHILD
/// sessions the root-filtered listing hides — NO `directory` filter
/// (directory-less roots are real, attachable rows the listing drops at
/// mapping) — and NO `time_archived` filter: opencode's `Session.get`
/// has no archived filter and a live attach to an archived session
/// succeeds (validated against v1.18.9), so archived rows answer
/// `Ok(true)`. The query matches the ATTACH arm, not the listing: any
/// filter the attach arm lacks would answer "absent" for an attachable
/// session — the false-dead-session bug class this function removes.
/// Schema note: only `id` is referenced, so legacy schemas lacking
/// `time_archived` answer normally.
///
/// - `Ok(false)` for a missing DB file (opencode never ran here) or no
/// matching row;
/// - `Err` for ANY read failure (lock contention, corruption, io error,
/// schema variance). LOAD-BEARING: callers must treat `Err` as
/// "unknown", never "absent" — an absent-on-error would let WAL lock
/// contention adjudicate live sessions dead.
pub fn session_exists_by_id(data_home: &Path, session_id: &str) -> Result<bool, OpencodeReadError> {
let db_path = data_home.join("opencode.db");
if !db_path.exists() {
return Ok(false);
}
let conn = Connection::open_with_flags(
&db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
)
.map_err(|e| OpencodeReadError(e.to_string()))?;
conn.busy_timeout(std::time::Duration::from_millis(
EXISTENCE_BY_ID_BUSY_TIMEOUT_MS,
))
.map_err(|e| OpencodeReadError(e.to_string()))?;
match conn.query_row(
"SELECT 1 FROM session WHERE id = ?1",
rusqlite::params![session_id],
|_| Ok(()),
) {
Ok(()) => Ok(true),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false),
Err(e) => Err(OpencodeReadError(e.to_string())),
}
}

/// `defaultOpencodeDataHome` — `$XDG_DATA_HOME/opencode` -> win `LOCALAPPDATA/opencode`
/// -> `~/.local/share/opencode`.
pub fn default_opencode_data_home() -> PathBuf {
Expand Down
200 changes: 200 additions & 0 deletions crates/freshell-sessions/tests/opencode_exists_by_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
//! Existence-probe by-id lookups against a temp `opencode.db` — the raw
//! query behind the server probe's opencode fallback (rebind dead-session
//! fix). Pins:
//! - CHILD rows (`parent_id` set) and directory-less ROOT rows are found
//! (the attach arm `opencode --session <id>` resolves both; the listing
//! hides both);
//! - ARCHIVED rows are found too: opencode's `Session.get` has no
//! `time_archived` filter and a live attach to an archived session
//! succeeds (validated against opencode v1.18.9) — the query matches
//! the ATTACH arm, not the listing;
//! - unknown ids are not found;
//! - a missing DB file is a benign "not found" (no opencode ever ran);
//! - an unreadable DB is a hard `Err` (the probe maps it to Unknown,
//! NEVER Absent);
//! - a legacy schema lacking `time_archived` still answers by id (the
//! query references only `id`).

use freshell_sessions::parse::session_exists_by_id;

fn temp_data_home(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"freshell-exists-by-id-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).expect("mkdir temp data home");
dir
}

/// Same schema shape as tests/opencode_sqlite.rs `create_full_schema`
/// (and the spike fixture): full modern schema including `parent_id`
/// and `time_archived`.
fn seed_schema(data_home: &std::path::Path) -> rusqlite::Connection {
let conn = rusqlite::Connection::open(data_home.join("opencode.db")).expect("open fixture db");
conn.execute_batch(
"CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT);
CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
time_created INTEGER,
time_updated INTEGER,
time_archived INTEGER,
project_id TEXT,
parent_id TEXT
);
CREATE TABLE part (session_id TEXT, data TEXT);
CREATE TABLE message (session_id TEXT, data TEXT);",
)
.expect("create schema");
conn
}

/// Insert one session row. `directory`, `parent_id`, `time_archived` are
/// the three axes these tests vary.
fn insert_row(
conn: &rusqlite::Connection,
id: &str,
directory: Option<&str>,
parent_id: Option<&str>,
time_archived: Option<i64>,
) {
conn.execute(
"INSERT INTO session VALUES (?1, ?2, 'T', 1000, 5000, ?4, NULL, ?3)",
rusqlite::params![id, directory, parent_id, time_archived],
)
.expect("insert session row");
}

#[test]
fn child_row_with_parent_id_is_found() {
let home = temp_data_home("child");
let conn = seed_schema(&home);
insert_row(
&conn,
"ses_root0000000000000000000000",
Some("/tmp/p"),
None,
None,
);
insert_row(
&conn,
"ses_child000000000000000000000",
Some("/tmp/p"),
Some("ses_root0000000000000000000000"),
None,
);
assert!(
session_exists_by_id(&home, "ses_child000000000000000000000").expect("query ok"),
"a CHILD row (parent_id set) IS on disk — no parent_id filter"
);
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn directory_less_root_row_is_found() {
let home = temp_data_home("dirless");
let conn = seed_schema(&home);
insert_row(&conn, "ses_dirless0000000000000000000", None, None, None);
assert!(
session_exists_by_id(&home, "ses_dirless0000000000000000000").expect("query ok"),
"a NULL-directory ROOT row IS on disk — no directory filter"
);
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn archived_row_is_found_attach_parity() {
// FALSIFIED-and-fixed premise: opencode's Session.get has NO archived
// filter (session.ts:542-546; archived filtering exists only in the
// list surface) and a live attach to an archived session succeeds.
// The probe must agree with the ATTACH arm — filtering archived rows
// would answer Absent for an attachable session (the bug class this
// fix removes).
let home = temp_data_home("archived");
let conn = seed_schema(&home);
insert_row(
&conn,
"ses_arch0000000000000000000000",
Some("/tmp/p"),
None,
Some(9999),
);
assert!(
session_exists_by_id(&home, "ses_arch0000000000000000000000").expect("query ok"),
"archived rows ARE attachable (`opencode --session <id>` resumes them) — found"
);
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn unknown_id_is_not_found() {
let home = temp_data_home("unknown-id");
let conn = seed_schema(&home);
insert_row(
&conn,
"ses_root0000000000000000000000",
Some("/tmp/p"),
None,
None,
);
assert!(!session_exists_by_id(&home, "ses_missing0000000000000000000").expect("query ok"));
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn missing_db_file_is_not_found_not_an_error() {
// Data home exists but opencode.db does not: opencode never ran here.
let home = temp_data_home("no-db");
assert!(!session_exists_by_id(&home, "ses_root0000000000000000000000").expect("benign"));
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn unreadable_db_is_an_error_never_not_found() {
// A DIRECTORY where the DB file should be: `exists()` passes, the
// read-only open fails — the corruption/io-error class, distinct from
// "no DB file". Callers map Err to Unknown, never Absent.
let home = temp_data_home("unreadable");
std::fs::create_dir_all(home.join("opencode.db")).expect("mkdir dir-as-db");
assert!(
session_exists_by_id(&home, "ses_root0000000000000000000000").is_err(),
"an unreadable DB must be a hard error, not a quiet 'not found'"
);
let _ = std::fs::remove_dir_all(&home);
}

#[test]
fn schema_missing_time_archived_still_answers_by_id() {
// The by-id query references only `id`, so — unlike the listing, which
// treats `time_archived` as a schema invariant and errors on a DB
// lacking it — a legacy schema still answers normally. Pins that the
// query has strictly fewer failure modes than the listing.
let home = temp_data_home("old-schema");
let conn = rusqlite::Connection::open(home.join("opencode.db")).expect("open fixture db");
conn.execute_batch(
"CREATE TABLE session (
id TEXT PRIMARY KEY,
directory TEXT,
title TEXT,
time_created INTEGER,
time_updated INTEGER
);",
)
.expect("create legacy schema");
conn.execute(
"INSERT INTO session VALUES ('ses_old00000000000000000000000', '/tmp/p', 'T', 1, 2)",
[],
)
.expect("insert");
assert!(
session_exists_by_id(&home, "ses_old00000000000000000000000").expect("query ok"),
"the query references only `id` — a legacy schema without \
time_archived answers normally"
);
let _ = std::fs::remove_dir_all(&home);
}
Loading
Loading