diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index dfcd3afe..01c23cd9 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -39,7 +39,7 @@ jobs: workspaces: "orbitdock-server -> ../.cache/rust/target" # Include workflow + Makefile in the key so cache busts on CI # or build-config changes, not just dependency changes. - shared-key: "rust-ci" + shared-key: "rust-ci-v2" cache-on-failure: true - name: rust fmt check diff --git a/orbitdock-server/crates/connector-codex/src/app_server/router.rs b/orbitdock-server/crates/connector-codex/src/app_server/router.rs index f1dcd0f9..4da10b63 100644 --- a/orbitdock-server/crates/connector-codex/src/app_server/router.rs +++ b/orbitdock-server/crates/connector-codex/src/app_server/router.rs @@ -52,10 +52,7 @@ pub(super) fn request_thread_id(request: &ServerRequest) -> Option { }) } -pub(super) async fn send_outputs( - route: &AppServerSessionRoute, - outputs: Vec, -) { +pub(super) async fn send_outputs(route: &AppServerSessionRoute, outputs: Vec) { for output in outputs { if route.forward_tx.send(output).is_err() { tracing::debug!("Typed codex app-server output channel closed"); diff --git a/orbitdock-server/crates/server/src/infrastructure/persistence/session_reads/startup_recovery.rs b/orbitdock-server/crates/server/src/infrastructure/persistence/session_reads/startup_recovery.rs index e3f1fc04..7544eae3 100644 --- a/orbitdock-server/crates/server/src/infrastructure/persistence/session_reads/startup_recovery.rs +++ b/orbitdock-server/crates/server/src/infrastructure/persistence/session_reads/startup_recovery.rs @@ -4,6 +4,8 @@ use std::path::PathBuf; use rusqlite::OptionalExtension; use rusqlite::{params, Connection}; +use crate::support::session_time::parse_unix_z; + use super::super::chrono_now; use super::super::messages::load_messages_from_db; use super::super::transcripts::load_messages_from_transcript; @@ -14,6 +16,16 @@ use super::projections::{ActiveSessionRow, RestoredSessionParts}; use super::RestoredSession; use orbitdock_protocol::SessionControlMode; +fn parse_timestamp_to_unix(value: Option<&str>) -> Option { + let raw = value?; + if let Some(unix) = parse_unix_z(Some(raw)) { + return Some(unix); + } + chrono::DateTime::parse_from_rfc3339(raw) + .ok() + .map(|parsed| parsed.timestamp().max(0) as u64) +} + #[cfg(test)] pub async fn load_session_lifecycle_state( id: &str, @@ -237,13 +249,8 @@ async fn load_sessions_for_startup_with_db_path( LEFT JOIN usage_session_state uss ON uss.session_id = s.id WHERE (s.status = 'active') OR (s.status = 'ended' AND s.end_reason = 'server_shutdown') - ORDER BY - COALESCE( - CAST(REPLACE(s.last_progress_at, 'Z', '') AS INTEGER), - CAST(REPLACE(s.last_activity_at, 'Z', '') AS INTEGER), - 0 - ) DESC, - COALESCE(CAST(REPLACE(s.last_activity_at, 'Z', '') AS INTEGER), 0) DESC", + ORDER BY CASE s.status WHEN 'active' THEN 0 ELSE 1 END, + COALESCE(s.last_progress_at, s.last_activity_at, s.started_at, '') DESC", )?; let session_rows: Vec = stmt @@ -376,6 +383,20 @@ async fn load_sessions_for_startup_with_db_path( })); } + sessions.sort_by(|left, right| { + let left_progress = parse_timestamp_to_unix(left.last_progress_at.as_deref()); + let right_progress = parse_timestamp_to_unix(right.last_progress_at.as_deref()); + let left_activity = parse_timestamp_to_unix(left.last_activity_at.as_deref()); + let right_activity = parse_timestamp_to_unix(right.last_activity_at.as_deref()); + let left_started = parse_timestamp_to_unix(left.started_at.as_deref()); + let right_started = parse_timestamp_to_unix(right.started_at.as_deref()); + + right_progress + .cmp(&left_progress) + .then_with(|| right_activity.cmp(&left_activity)) + .then_with(|| right_started.cmp(&left_started)) + }); + Ok(sessions) }) .await??; diff --git a/orbitdock-server/crates/server/src/infrastructure/persistence/tests.rs b/orbitdock-server/crates/server/src/infrastructure/persistence/tests.rs index 466f4e7e..ff32a270 100644 --- a/orbitdock-server/crates/server/src/infrastructure/persistence/tests.rs +++ b/orbitdock-server/crates/server/src/infrastructure/persistence/tests.rs @@ -1172,6 +1172,112 @@ fn startup_restore_only_ends_stale_passive_codex_sessions() { assert_eq!(approval_status.1, "permission"); } +#[test] +fn startup_restore_sorts_mixed_timestamp_formats_by_actual_recency() { + let (conn, db_path, _dir, _guard) = setup_test_db(); + + conn + .execute( + "INSERT INTO sessions ( + id, provider, status, work_status, lifecycle_state, control_mode, + project_path, codex_integration_mode, codex_thread_id, started_at, last_activity_at, last_progress_at + ) VALUES ( + 'iso-recent', 'codex', 'active', 'waiting', 'open', 'direct', + '/tmp/test', 'direct', 'thread-iso-recent', '2026-05-01T14:00:00Z', '2026-05-01T14:10:34Z', '2026-05-01T14:10:34Z' + )", + [], + ) + .unwrap(); + conn + .execute( + "INSERT INTO sessions ( + id, provider, status, work_status, lifecycle_state, control_mode, + project_path, codex_integration_mode, codex_thread_id, started_at, last_activity_at, last_progress_at + ) VALUES ( + 'unix-stale', 'codex', 'active', 'waiting', 'open', 'direct', + '/tmp/test', 'direct', 'thread-unix-stale', '2026-04-16T20:34:39Z', '1776438552Z', '1776438552Z' + )", + [], + ) + .unwrap(); + drop(conn); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let restored = runtime + .block_on(super::session_reads::load_sessions_for_startup_from_db_path(db_path)) + .unwrap(); + + let iso_index = restored + .iter() + .position(|session| session.id == "iso-recent") + .expect("iso session should be restored"); + let unix_index = restored + .iter() + .position(|session| session.id == "unix-stale") + .expect("unix session should be restored"); + + assert!( + iso_index < unix_index, + "RFC3339 timestamps should sort ahead of older unix-with-Z timestamps" + ); +} + +#[test] +fn startup_restore_uses_started_at_as_final_tiebreaker() { + let (conn, db_path, _dir, _guard) = setup_test_db(); + + conn + .execute( + "INSERT INTO sessions ( + id, provider, status, work_status, lifecycle_state, control_mode, + project_path, codex_integration_mode, codex_thread_id, started_at, last_activity_at, last_progress_at + ) VALUES ( + 'newer-start', 'codex', 'active', 'waiting', 'open', 'direct', + '/tmp/test', 'direct', 'thread-newer-start', '2026-05-01T14:11:00Z', '2026-05-01T14:10:34Z', '2026-05-01T14:10:34Z' + )", + [], + ) + .unwrap(); + conn + .execute( + "INSERT INTO sessions ( + id, provider, status, work_status, lifecycle_state, control_mode, + project_path, codex_integration_mode, codex_thread_id, started_at, last_activity_at, last_progress_at + ) VALUES ( + 'older-start', 'codex', 'active', 'waiting', 'open', 'direct', + '/tmp/test', 'direct', 'thread-older-start', '2026-05-01T14:09:00Z', '2026-05-01T14:10:34Z', '2026-05-01T14:10:34Z' + )", + [], + ) + .unwrap(); + drop(conn); + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let restored = runtime + .block_on(super::session_reads::load_sessions_for_startup_from_db_path(db_path)) + .unwrap(); + + let newer_index = restored + .iter() + .position(|session| session.id == "newer-start") + .expect("newer-start session should be restored"); + let older_index = restored + .iter() + .position(|session| session.id == "older-start") + .expect("older-start session should be restored"); + + assert!( + newer_index < older_index, + "started_at should break ties after progress and activity timestamps" + ); +} + #[test] fn startup_restore_marks_open_direct_sessions_as_resumable() { let (conn, db_path, _dir, _guard) = setup_test_db();