Merge pull request #3216 from TheArchitectit/worktree-session-resume-fixes

fix: session resume — scan all workspaces, skip current empty session
This commit is contained in:
Bellman
2026-06-05 10:38:19 +09:00
committed by GitHub
2 changed files with 223 additions and 36 deletions

View File

@@ -93,8 +93,19 @@ impl SessionStore {
}
pub fn resolve_reference(&self, reference: &str) -> Result<SessionHandle, SessionControlError> {
self.resolve_reference_excluding(reference, None)
}
/// Resolve a session reference, optionally excluding a session by ID.
/// When the reference is an alias, the excluded session is skipped
/// so /resume latest returns the previous session, not the current one.
pub fn resolve_reference_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<SessionHandle, SessionControlError> {
if is_session_reference_alias(reference) {
let latest = self.latest_session()?;
let latest = self.latest_session_excluding(exclude_id)?;
return Ok(SessionHandle {
id: latest.id,
path: latest.path,
@@ -158,12 +169,45 @@ impl SessionStore {
}
pub fn latest_session(&self) -> Result<ManagedSessionSummary, SessionControlError> {
if let Some(latest) = self.list_sessions()?.into_iter().next() {
self.latest_session_excluding(None)
}
/// Find the most recent session, optionally excluding a session by ID
/// and skipping sessions with 0 messages. Used by /resume latest to skip
/// the current empty session and find the previous session with actual
/// conversation history.
pub fn latest_session_excluding(
&self,
exclude_id: Option<&str>,
) -> Result<ManagedSessionSummary, SessionControlError> {
let exclude = exclude_id.unwrap_or("");
// First: look in the current workspace's session namespace
if let Some(latest) = self
.list_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
return Ok(latest);
}
if let Some(latest) = self.scan_global_sessions()?.into_iter().next() {
// Fallback: scan all workspace namespaces under ~/.claw/sessions/
// and project-local .claw/sessions/ so /resume latest finds sessions
// from other workspaces.
if let Some(latest) = self
.scan_global_sessions()?
.into_iter()
.find(|s| s.id != exclude && s.message_count > 0)
{
return Ok(latest);
}
// Distinguish between "no sessions at all" and "sessions exist but
// all are empty" so the user gets a clear signal about what to do.
let has_any_session = self.list_sessions()?.iter().any(|s| s.id != exclude)
|| self.scan_global_sessions()?.iter().any(|s| s.id != exclude);
if has_any_session {
return Err(SessionControlError::Format(format_all_sessions_empty(
&self.sessions_root,
)));
}
Err(SessionControlError::Format(format_no_managed_sessions(
&self.sessions_root,
)))
@@ -204,28 +248,41 @@ impl SessionStore {
&self,
reference: &str,
) -> Result<LoadedManagedSession, SessionControlError> {
match self.load_session(reference) {
Ok(loaded) => Ok(loaded),
Err(SessionControlError::WorkspaceMismatch { expected, actual })
if is_session_reference_alias(reference) =>
self.load_session_excluding(reference, None)
}
/// Like `load_session_loose` but also excludes a session by ID.
/// Used by /resume latest to skip the current empty session and find
/// the previous session with actual conversation history.
pub fn load_session_excluding(
&self,
reference: &str,
exclude_id: Option<&str>,
) -> Result<LoadedManagedSession, SessionControlError> {
let handle = self.resolve_reference_excluding(reference, exclude_id)?;
let session = Session::load_from_path(&handle.path)?;
// For alias references, allow cross-workspace resume
if is_session_reference_alias(reference) {
if let Err(SessionControlError::WorkspaceMismatch {
expected: _,
actual,
}) = self.validate_loaded_session(&handle.path, &session)
{
let handle = self.resolve_reference(reference)?;
let session = Session::load_from_path(&handle.path)?;
eprintln!(
" Note: resuming session from a different workspace (origin: {})",
actual.display()
);
let _ = expected; // suppress unused warning
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
Err(other) => Err(other),
} else {
self.validate_loaded_session(&handle.path, &session)?;
}
Ok(LoadedManagedSession {
handle: SessionHandle {
id: session.session_id.clone(),
path: handle.path,
},
session,
})
}
pub fn fork_session(
@@ -726,6 +783,16 @@ fn format_no_managed_sessions(sessions_root: &Path) -> String {
)
}
fn format_all_sessions_empty(sessions_root: &Path) -> String {
let fingerprint_dir = sessions_root
.file_name()
.and_then(|f| f.to_str())
.unwrap_or("<unknown>");
format!(
"all sessions are empty (0 messages) in .claw/sessions/{fingerprint_dir}/\nThis usually means a fresh `claw` session is running but no messages have been sent yet.\nWait for a response in your other session, then try `--resume {LATEST_SESSION_REFERENCE}` again."
)
}
fn format_legacy_session_missing_workspace_root(
session_path: &Path,
workspace_root: &Path,
@@ -1220,6 +1287,114 @@ mod tests {
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_returns_all_empty_error_when_sessions_exist_but_have_no_messages() {
// given — create sessions with 0 messages (empty)
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
// when — latest_session should fail with the "all sessions empty" message
let result = store.latest_session();
assert!(
result.is_err(),
"latest_session should fail when all sessions are empty"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("all sessions are empty"),
"error should mention 'all sessions are empty', got: {err_msg}"
);
assert!(
err_msg.contains("0 messages"),
"error should mention '0 messages', got: {err_msg}"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_excluding_skips_excluded_id_and_returns_previous() {
// given — two sessions WITH messages, newest excluded
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — exclude the newest session
let latest = store
.latest_session_excluding(Some(&newer.session_id))
.expect("latest excluding newest should resolve");
// then — the older session wins because the newest is skipped
assert_eq!(
latest.id, older.session_id,
"excluded id must be skipped, returning the previous session"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn latest_session_filters_out_zero_message_sessions() {
// given — one empty (0-message) session and one non-empty session
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let empty_handle = store.create_handle("empty-session");
Session::new()
.with_persistence_path(empty_handle.path.clone())
.save_to_path(&empty_handle.path)
.expect("empty session should save");
wait_for_next_millisecond();
let non_empty = persist_session_via_store(&store, "real conversation");
// when
let latest = store.latest_session().expect("latest should resolve");
// then — the non-empty session wins; the 0-message one is filtered out
assert_eq!(
latest.id, non_empty.session_id,
"0-message session must be filtered out, non-empty session wins"
);
assert!(
latest.message_count > 0,
"resolved session must have messages"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn resolve_reference_excluding_latest_skips_excluded_id() {
// given — two sessions WITH messages
let base = temp_dir();
fs::create_dir_all(&base).expect("base dir should exist");
let store = SessionStore::from_cwd(&base).expect("store should build");
let older = persist_session_via_store(&store, "older work");
wait_for_next_millisecond();
let newer = persist_session_via_store(&store, "newer work");
// when — resolve the "latest" alias while excluding the newest session
let handle = store
.resolve_reference_excluding("latest", Some(&newer.session_id))
.expect("latest alias excluding newest should resolve");
// then — the excluded id is skipped, so the older session resolves
assert_eq!(
handle.id, older.session_id,
"excluded id must be skipped when resolving the latest alias"
);
fs::remove_dir_all(base).expect("temp dir should clean up");
}
#[test]
fn session_exists_and_delete_are_scoped_to_workspace_store() {
// given

View File

@@ -8431,7 +8431,8 @@ impl LiveCli {
return Ok(false);
};
let (handle, session) = load_session_reference(&session_ref)?;
let (handle, session) =
load_session_reference_excluding(&session_ref, Some(&self.session.id))?;
let message_count = session.messages.len();
let session_id = session.session_id.clone();
let runtime = build_runtime(
@@ -9127,17 +9128,18 @@ fn latest_managed_session() -> Result<ManagedSessionSummary, Box<dyn std::error:
fn load_session_reference(
reference: &str,
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
load_session_reference_excluding(reference, None)
}
fn load_session_reference_excluding(
reference: &str,
exclude_id: Option<&str>,
) -> Result<(SessionHandle, Session), Box<dyn std::error::Error>> {
let store = current_session_store()?;
// For alias references ("latest", "last", "recent"), allow cross-workspace
// resume so /resume latest finds the most recent session globally.
// For explicit references, workspace validation is enforced.
let result = if runtime::session_control::is_session_reference_alias(reference) {
store.load_session_loose(reference)
} else {
store.load_session(reference)
};
let loaded = result.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
let loaded = store
.load_session_excluding(reference, exclude_id)
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)?;
Ok((
SessionHandle {
id: loaded.handle.id,
@@ -18386,16 +18388,26 @@ UU conflicted.rs",
std::env::set_current_dir(&workspace).expect("switch cwd");
let older = create_managed_session_handle("session-older").expect("older handle");
Session::new()
.with_persistence_path(older.path.clone())
.save_to_path(&older.path)
.expect("older session should save");
{
let mut session = Session::new().with_persistence_path(older.path.clone());
session
.push_user_text("older session message")
.expect("older message should save");
session
.save_to_path(&older.path)
.expect("older session should save");
}
std::thread::sleep(Duration::from_millis(20));
let newer = create_managed_session_handle("session-newer").expect("newer handle");
Session::new()
.with_persistence_path(newer.path.clone())
.save_to_path(&newer.path)
.expect("newer session should save");
{
let mut session = Session::new().with_persistence_path(newer.path.clone());
session
.push_user_text("newer session message")
.expect("newer message should save");
session
.save_to_path(&newer.path)
.expect("newer session should save");
}
let resolved = resolve_session_reference("latest").expect("latest session should resolve");
assert_eq!(