use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde_json::{Value, json};
use crate::core::catchup::resolve::{
CallerIdentity, redact_sessions_not_owned_by, resolve_snapshot_for_identity,
};
use crate::core::catchup::{CatchupOptions, generate_catchup_json};
use crate::core::config::MpmConfig;
use crate::daemon::catchup_payload::{HydrationReceipt, catchup_payload, resolved_note};
use crate::daemon::catchup_superseded::assess_snapshot;
use crate::daemon::state::DaemonState;
fn derive_caller_session_id(tmux_window: Option<&str>) -> Option<String> {
trusty_common::catchup::session_id::derive_session_id(None, tmux_window)
}
pub async fn session_context_catchup(
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> Result<Value, String> {
session_context_catchup_with_session_created(
project_dir,
session_id,
tmux_window,
None,
all_projects,
full,
sessions_offset,
)
.await
}
pub async fn session_context_catchup_with_session_created(
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
tmux_session_created: Option<i64>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> Result<Value, String> {
let host = CatchupHost {
config: MpmConfig::load_default(),
memory_socket: trusty_common::memory_rpc::resolve_memory_socket_or_unreachable(),
};
session_context_catchup_in(
&host,
project_dir,
session_id,
tmux_window,
tmux_session_created,
all_projects,
full,
sessions_offset,
)
.await
}
pub(crate) struct CatchupHost {
pub(crate) config: MpmConfig,
pub(crate) memory_socket: PathBuf,
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn session_context_catchup_in(
host: &CatchupHost,
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
tmux_session_created: Option<i64>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> Result<Value, String> {
let primary = PathBuf::from(project_dir);
if !primary.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
let derived = derive_caller_session_id(tmux_window);
let session_id = session_id.or(derived.as_deref());
let mut project_dirs = vec![primary.clone()];
if all_projects {
let registry = crate::core::claude_mpm_registry::default_registry_path();
match crate::core::claude_mpm_registry::discover_claude_mpm_projects(®istry) {
Ok(extra) => {
for p in extra {
if !project_dirs.contains(&p) {
project_dirs.push(p);
}
}
}
Err(e) => {
eprintln!(
"session_context_catchup: warning: could not read claude-mpm registry: {e}"
);
}
}
}
let config = &host.config;
let session_refs = if config.session_refs.enabled {
hydrate_session_refs(&primary, session_id).await
} else {
HydrationReceipt::default()
};
let mut merged = trusty_common::catchup::CatchupJson::default();
let caller = CallerIdentity::new(session_id, tmux_window)
.with_tmux_session_created(tmux_session_created);
for dir in &project_dirs {
let opts = CatchupOptions {
project_dir: dir.clone(),
memory_socket: host.memory_socket.clone(),
include_git: config.catchup.include_git,
include_palace: config.catchup.include_palace,
git_limit: config.catchup.git_limit,
drawer_limit: config.catchup.drawer_limit,
full,
};
let mut digest = generate_catchup_json(&opts).await;
redact_sessions_not_owned_by(dir, &caller, &mut digest.sessions);
merged.absorb(digest);
}
let resolved = resolve_snapshot_for_identity(&primary, &caller);
let freshness = resolved
.as_ref()
.map(|r| assess_snapshot(&primary, &r.path))
.unwrap_or_default();
let note = resolved_note(resolved.as_ref(), &caller);
Ok(catchup_payload(
merged,
sessions_offset,
resolved,
note,
session_refs,
freshness,
))
}
async fn hydrate_session_refs(project_dir: &Path, session_id: Option<&str>) -> HydrationReceipt {
let Some(target) = session_ref_target(project_dir, session_id) else {
let error = "no session ref can be attributed to this caller: it named no \
session id, or no user id resolved for this checkout"
.to_string();
tracing::warn!(project = %project_dir.display(), "session-ref hydration skipped: {error}");
return HydrationReceipt {
error: Some(error),
..HydrationReceipt::default()
};
};
let dir = project_dir.to_path_buf();
let hydrated = tokio::task::spawn_blocking(move || {
trusty_common::catchup::session_refs::hydrate_session_cache(&dir, &target)
})
.await;
match hydrated {
Ok(Ok(outcome)) => {
tracing::debug!(
refs_seen = outcome.refs_seen,
own_ref_found = outcome.own_ref_found,
snapshots = outcome.snapshots_written.len(),
log_entries = outcome.log_entries_added,
"session-ref hydration finished"
);
HydrationReceipt {
hydrated: true,
refs_seen: outcome.refs_seen,
own_ref_found: outcome.own_ref_found,
restored: outcome.snapshots_written.len(),
error: None,
}
}
Ok(Err(e)) => {
let error = e.to_string();
tracing::warn!(
project = %project_dir.display(),
"session-ref hydration failed; the local cache was not refreshed: {error}"
);
HydrationReceipt {
error: Some(error),
..HydrationReceipt::default()
}
}
Err(e) => {
let error = format!("session-ref hydration task failed: {e}");
tracing::warn!("{error}");
HydrationReceipt {
error: Some(error),
..HydrationReceipt::default()
}
}
}
}
fn session_ref_target(
project_dir: &Path,
session_id: Option<&str>,
) -> Option<trusty_common::catchup::session_refs::SessionRefTarget> {
use crate::core::session_ref_publish::{resolve_user_id, session_key};
let session_id = session_id?;
Some(trusty_common::catchup::session_refs::SessionRefTarget {
user_id: resolve_user_id(project_dir)?,
session_key: session_key(session_id)?,
session_id: session_id.to_string(),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn session_context_pause(
state: &Arc<DaemonState>,
project_dir: &str,
session_id: Option<&str>,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
) -> Result<Value, String> {
let session_refs_enabled = crate::core::config::MpmConfig::load_default()
.session_refs
.enabled;
session_context_pause_with(
state,
project_dir,
session_id,
summary,
completed,
in_progress,
next_steps,
tmux_window,
prune_worktrees,
session_refs_enabled,
)
.await
}
async fn prune_for_pause(
mgr: &crate::session_manager::SessionManager,
repos_root: &Path,
adopted: &[PathBuf],
project_path: &Path,
) -> (Vec<String>, Vec<crate::session_manager::DirtyWorktree>) {
use crate::session_manager::worktree_scope::{WorktreeScope, project_root_for};
let Some(scope) =
project_root_for(project_path).and_then(|r| WorktreeScope::for_project(&r).ok())
else {
tracing::info!(
"session_context_pause: {} is not in a git checkout; no worktree prune (#8782)",
project_path.display()
);
return (Vec::new(), Vec::new());
};
let in_use: Vec<PathBuf> = mgr
.list()
.await
.iter()
.filter_map(|r| r.workspace_path.clone())
.collect();
let policy = crate::session_manager::DirtyWorktreePolicy::Skip;
match mgr
.prune_orphaned_worktrees_in(repos_root, &in_use, false, policy, adopted, &scope)
.await
{
Ok(sweep) => (
sweep
.removed
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect(),
sweep.skipped_dirty,
),
Err(e) => {
tracing::warn!("session_context_pause: worktree prune failed: {e}");
(Vec::new(), Vec::new())
}
}
}
#[cfg(test)]
#[path = "mcp_context_pause_scope_tests.rs"]
mod pause_scope_tests;
#[allow(clippy::too_many_arguments)]
async fn session_context_pause_with(
state: &Arc<DaemonState>,
project_dir: &str,
session_id: Option<&str>,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
session_refs_enabled: bool,
) -> Result<Value, String> {
let project_path = PathBuf::from(project_dir);
if !project_path.is_dir() {
return Err(format!(
"project_dir does not exist or is not a directory: {project_dir}"
));
}
if summary.trim().is_empty() {
return Err("`summary` must not be empty".to_string());
}
let derived = derive_caller_session_id(tmux_window);
let Some(session_id) = session_id
.map(str::trim)
.filter(|s| !s.is_empty())
.or(derived.as_deref())
else {
return Err(
"`session_id` was omitted and could not be derived: this caller is neither a \
tm-managed session nor running inside tmux. Pass `session_id` explicitly."
.to_string(),
);
};
let input = trusty_common::catchup::pause::PauseSnapshotInput {
session_id,
summary,
completed: &completed,
in_progress: &in_progress,
next_steps: &next_steps,
tmux_window,
};
let outcome = trusty_common::catchup::pause::write_pause_snapshot(&project_path, &input)
.map_err(|e| format!("failed to write pause snapshot: {e}"))?;
let receipt =
publish_session_ref(&project_path, session_id, &outcome, session_refs_enabled).await;
let (pruned_worktrees, skipped_dirty) = if prune_worktrees {
let mgr = state.session_manager().await;
let tt_config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
let repos_root = crate::core::trusty_tools_config::workspace_root(&tt_config);
let adopted = crate::project::adopted_anchors_under(state.framework_root());
prune_for_pause(&mgr, &repos_root, &adopted, &project_path).await
} else {
(Vec::new(), Vec::new())
};
let session_name = managed_session_name(state, session_id).await;
let publish =
publish_snapshot(&project_path, session_id, session_name.as_deref(), &outcome).await;
let publish_json = match publish {
Ok(SnapshotPublish::Opened(out)) => json!({
"status": "opened",
"branch": out.branch,
"commit": out.commit,
"pr_url": out.pr_url,
"auto_merge_armed": out.auto_merge_armed,
"auto_merge_error": out.auto_merge_error,
}),
Ok(SnapshotPublish::Skipped(reason)) => json!({
"status": "skipped",
"reason": reason,
}),
Err(e) => {
return Err(format!(
"pause snapshot written to {}, but publishing it failed: {e} \
(worktree prune: {} removed, {} skipped as dirty)",
outcome.snapshot_path.display(),
pruned_worktrees.len(),
skipped_dirty.len()
));
}
};
Ok(json!({
"session_id": session_id,
"snapshot_path": outcome.snapshot_path.display().to_string(),
"timestamp": outcome.timestamp.to_rfc3339(),
"pruned_worktrees": pruned_worktrees,
"skipped_dirty_worktrees": skipped_dirty,
"snapshot_publish": publish_json,
"ref_name": receipt.ref_name,
"ref_published": receipt.published,
"ref_error": receipt.error,
}))
}
async fn publish_session_ref(
project_path: &Path,
session_id: &str,
outcome: &trusty_common::catchup::pause::PauseSnapshotOutcome,
enabled: bool,
) -> crate::core::session_ref_publish::SessionRefReceipt {
use crate::core::session_ref_publish::{SessionRefReceipt, SessionRefRequest, publish_receipt};
let repo = project_path.to_path_buf();
let session = session_id.to_string();
let snapshot = outcome.snapshot_path.clone();
let timestamp = outcome.timestamp;
tokio::task::spawn_blocking(move || {
publish_receipt(
&SessionRefRequest {
repo: &repo,
session_id: &session,
snapshot_path: &snapshot,
timestamp,
},
enabled,
)
})
.await
.unwrap_or_else(|e| SessionRefReceipt {
ref_name: None,
published: false,
error: Some(format!("session ref publish task failed: {e}")),
})
}
async fn publish_snapshot(
project_path: &Path,
session_id: &str,
session_name: Option<&str>,
outcome: &trusty_common::catchup::pause::PauseSnapshotOutcome,
) -> Result<SnapshotPublish, String> {
use crate::core::session_pause_pr as pause_pr;
let Ok(rel) = outcome.snapshot_path.strip_prefix(project_path) else {
return Err(format!(
"snapshot {} is not inside {}",
outcome.snapshot_path.display(),
project_path.display()
));
};
let paths = vec![
rel.to_string_lossy().replace('\\', "/"),
format!("{}sessions-log.jsonl", pause_pr::SESSIONS_PREFIX),
];
let repo = project_path.to_path_buf();
let session = session_id.to_string();
let name = session_name.map(str::to_string);
let timestamp = outcome.timestamp;
let default_branch = configured_default_branch(project_path);
if crate::core::remote_mode::remote_mode(project_path).is_ok_and(|m| m.is_local_only()) {
tracing::info!(
"session pause: {}",
crate::core::remote_mode::LOCAL_ONLY_SKIP
);
return Ok(SnapshotPublish::Skipped("local_only"));
}
tokio::task::spawn_blocking(move || {
let req = pause_pr::PublishRequest {
repo: &repo,
session_id: &session,
session_name: name.as_deref(),
timestamp,
paths,
default_branch: default_branch.as_deref(),
};
match pause_pr::publish_pause_snapshot(&pause_pr::RealPauseVcs, &req) {
Ok(Some(out)) => Ok(SnapshotPublish::Opened(out)),
Ok(None) => Ok(SnapshotPublish::Skipped("unchanged")),
Err(pause_pr::PublishError::NotTracked(_)) => {
Ok(SnapshotPublish::Skipped("not_tracked"))
}
Err(pause_pr::PublishError::NotAGitRepo(_)) => {
Ok(SnapshotPublish::Skipped("not_a_git_repo"))
}
Err(e) => Err(e.to_string()),
}
})
.await
.map_err(|e| format!("snapshot publish task failed: {e}"))?
}
async fn managed_session_name(state: &Arc<DaemonState>, session_id: &str) -> Option<String> {
let manager = state.session_manager().await;
manager
.list()
.await
.into_iter()
.find(|record| record.id.to_string() == session_id)
.map(|record| record.tmux_name)
}
#[derive(Debug)]
enum SnapshotPublish {
Opened(crate::core::session_pause_pr::PublishOutcome),
Skipped(&'static str),
}
fn configured_default_branch(project_path: &Path) -> Option<String> {
let name = project_path.file_name()?.to_str()?.to_string();
let config = crate::core::trusty_tools_config::TrustyToolsConfig::load();
default_branch_for(&config.projects, &name)
}
fn default_branch_for(
projects: &[crate::core::trusty_tools_config::ProjectConfig],
name: &str,
) -> Option<String> {
projects
.iter()
.find(|p| p.name == name)
.and_then(|p| p.default_branch.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::catchup::resolve::ResolvedSnapshot;
use crate::daemon::catchup_bounds::CATCHUP_BUDGET_BYTES;
use crate::daemon::catchup_payload::TMUX_SESSION_ROUTE_NOT_TRIED;
use crate::daemon::catchup_superseded::SnapshotFreshness;
fn isolated_host() -> CatchupHost {
CatchupHost {
config: MpmConfig::default(),
memory_socket: PathBuf::from("/nonexistent/mcp-context-test.sock"),
}
}
async fn isolated_catchup(
project_dir: &str,
session_id: Option<&str>,
tmux_window: Option<&str>,
all_projects: bool,
full: bool,
sessions_offset: usize,
) -> Result<Value, String> {
let host = isolated_host();
session_context_catchup_in(
&host,
project_dir,
session_id,
tmux_window,
None,
all_projects,
full,
sessions_offset,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn isolated_pause(
state: &Arc<DaemonState>,
project_dir: &str,
session_id: Option<&str>,
summary: &str,
completed: Vec<String>,
in_progress: Vec<String>,
next_steps: Vec<String>,
tmux_window: Option<&str>,
prune_worktrees: bool,
) -> Result<Value, String> {
session_context_pause_with(
state,
project_dir,
session_id,
summary,
completed,
in_progress,
next_steps,
tmux_window,
prune_worktrees,
false,
)
.await
}
#[tokio::test]
async fn publish_snapshot_reports_not_a_git_repo_as_the_skip_reason() {
let tmp = tempfile::TempDir::new().unwrap();
let snapshot = tmp
.path()
.join(".trusty-mpm/sessions/s/session-20260909-183015.md");
std::fs::create_dir_all(snapshot.parent().unwrap()).unwrap();
std::fs::write(&snapshot, b"# Session Pause\n").unwrap();
let outcome = trusty_common::catchup::pause::PauseSnapshotOutcome {
snapshot_path: snapshot,
timestamp: chrono::Utc::now(),
};
let publish = publish_snapshot(tmp.path(), "s", None, &outcome)
.await
.unwrap();
let SnapshotPublish::Skipped(reason) = publish else {
panic!("{publish:?}");
};
assert_eq!(reason, "not_a_git_repo");
}
#[test]
fn configured_default_branch_reads_the_projects_section() {
use crate::core::trusty_tools_config::ProjectConfig;
let projects = vec![
ProjectConfig {
name: "on-develop".into(),
default_branch: Some("develop".into()),
..ProjectConfig::default()
},
ProjectConfig {
name: "undeclared".into(),
..ProjectConfig::default()
},
];
assert_eq!(
default_branch_for(&projects, "on-develop").as_deref(),
Some("develop")
);
assert_eq!(default_branch_for(&projects, "undeclared"), None);
assert_eq!(default_branch_for(&projects, "unregistered"), None);
}
#[tokio::test]
async fn session_context_catchup_missing_project_dir_errors() {
let err = isolated_catchup("/nonexistent/does/not/exist", None, None, false, true, 0)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_catchup_returns_expected_shape() {
let tmp = tempfile::TempDir::new().unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.arg("-C")
.arg(tmp.path())
.args(args)
.output()
.unwrap();
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "T"]);
std::fs::write(tmp.path().join("a.txt"), b"a").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "init"]);
let result = isolated_catchup(tmp.path().to_str().unwrap(), None, None, false, true, 0)
.await
.unwrap();
assert_eq!(result["watermark_advanced"], false);
assert!(result["sessions"].is_array());
assert!(result["recent_commits"].is_array());
assert!(result["recent_memory"].is_array());
assert!(result["resolved_snapshot"].is_null());
assert!(result["resolved_via"].is_null());
assert_eq!(result["undatable_sessions_dropped"], 0);
}
#[test]
fn catchup_payload_carries_the_undatable_drop_count() {
let merged = trusty_common::catchup::CatchupJson {
undatable_sessions_dropped: 4,
..Default::default()
};
let resolved = ResolvedSnapshot::new(
PathBuf::from("/tmp/snap.md"),
crate::core::catchup::resolve::ResolutionPath::TmuxWindow,
);
let body = catchup_payload(
merged,
0,
Some(resolved),
None,
HydrationReceipt::default(),
SnapshotFreshness::default(),
);
assert_eq!(
body["undatable_sessions_dropped"], 4,
"the withheld count must reach the wire: {body}"
);
assert_eq!(body["resolved_snapshot"], "/tmp/snap.md");
assert_eq!(
body["resolved_via"], "tmux_window",
"a fallback must never be presented as an exact match: {body}"
);
assert_eq!(body["watermark_advanced"], false);
assert_eq!(
body["resolved_snapshot_superseded"], false,
"#7501: a snapshot nothing has moved past is not superseded: {body}"
);
}
#[test]
fn catchup_payload_reports_a_superseded_snapshot() {
let resolved = ResolvedSnapshot::new(
PathBuf::from("/tmp/snap.md"),
crate::core::catchup::resolve::ResolutionPath::SessionId,
);
let freshness = SnapshotFreshness {
superseded: true,
commits_since: vec!["abc1234 the work that already landed".to_string()],
total_since: 7,
};
let body = catchup_payload(
trusty_common::catchup::CatchupJson::default(),
0,
Some(resolved),
None,
HydrationReceipt::default(),
freshness,
);
assert_eq!(body["resolved_snapshot_superseded"], true, "{body}");
assert_eq!(
body["commits_since_snapshot"][0], "abc1234 the work that already landed",
"the PM needs WHAT landed, not only that something did: {body}"
);
assert_eq!(
body["commits_since_snapshot_total"], 7,
"a capped list must never read as the complete one: {body}"
);
}
#[tokio::test]
async fn session_context_catchup_never_resolves_another_sessions_snapshot() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let session_a = "2eb72dca-de08-481b-8dfa-22ab7f81b1f9";
let session_b = "7bd5c27a-475b-41df-9e9f-a6f630801717";
let paused = isolated_pause(
&state,
dir,
Some(session_a),
"Session A's work.",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap();
let a_snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let for_b = isolated_catchup(dir, Some(session_b), None, false, true, 0)
.await
.unwrap();
assert!(
for_b["resolved_snapshot"].is_null(),
"B must not be handed A's snapshot: {}",
for_b["resolved_snapshot"]
);
let for_a = isolated_catchup(dir, Some(session_a), None, false, true, 0)
.await
.unwrap();
assert_eq!(for_a["resolved_snapshot"], a_snapshot);
assert_eq!(for_a["resolved_via"], "session_id");
let anonymous = isolated_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
assert!(anonymous["resolved_snapshot"].is_null());
}
#[tokio::test]
async fn session_context_catchup_resolves_by_tmux_window_after_a_relaunch() {
let tmp = tempfile::TempDir::new().unwrap();
let other = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let window = "tm-dogfood:0:@230";
let paused = isolated_pause(
&state,
dir,
Some("e262f4c5-d309-4203-ad3b-e0c29084d87e"),
"Work from the previous incarnation.",
vec![],
vec![],
vec![],
Some(window),
false,
)
.await
.unwrap();
let snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let relaunched = isolated_catchup(
dir,
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(relaunched["resolved_snapshot"], snapshot);
assert_eq!(relaunched["resolved_via"], "tmux_window");
let elsewhere = isolated_catchup(
other.path().to_str().unwrap(),
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert!(
elsewhere["resolved_snapshot"].is_null(),
"another project's snapshot must not resolve: {}",
elsewhere["resolved_snapshot"]
);
for bad in ["", "tm-dogfood", "tm-dogfood:0"] {
let malformed = isolated_catchup(dir, Some("never-paused"), Some(bad), false, true, 0)
.await
.unwrap();
assert!(
malformed["resolved_snapshot"].is_null(),
"{bad:?} must resolve nothing"
);
assert!(malformed["resolved_via"].is_null());
}
}
#[tokio::test]
async fn session_context_catchup_resolves_by_tmux_session_after_the_window_id_changes() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let paused = isolated_pause(
&state,
dir,
None,
"Before the relaunch.",
vec![],
vec![],
vec![],
Some("tm-supervisor:0:@258"),
false,
)
.await
.unwrap();
let snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let window = Some("tm-supervisor:0:@262");
let catchup = |created: Option<i64>| {
let host = isolated_host();
async move {
session_context_catchup_in(&host, dir, None, window, created, false, true, 0).await
}
};
let relaunched = catchup(Some(1_600_000_000)).await.unwrap();
assert_eq!(relaunched["resolved_snapshot"], snapshot, "{relaunched}");
assert_eq!(relaunched["resolved_via"], "tmux_session");
assert!(relaunched.get("resolved_note").is_none(), "{relaunched}");
let future = chrono::Utc::now().timestamp() + 3600;
for (created, note) in [
(None, Some(TMUX_SESSION_ROUTE_NOT_TRIED)),
(Some(future), None),
] {
let body = catchup(created).await.unwrap();
assert!(body["resolved_snapshot"].is_null(), "{created:?}: {body}");
assert_eq!(body.get("resolved_note").and_then(Value::as_str), note);
}
}
fn summaries(body: &Value) -> Vec<String> {
body["sessions"]
.as_array()
.unwrap()
.iter()
.map(|s| s["summary"].as_str().unwrap_or_default().to_string())
.collect()
}
fn seed_snapshots(project: &std::path::Path, n: usize, body_bytes: usize) {
let dir = project.join(".trusty-mpm").join("sessions");
std::fs::create_dir_all(&dir).unwrap();
let body = "x".repeat(body_bytes);
for i in 0..n {
std::fs::write(
dir.join(format!("session-20260801-12{:02}{:02}.md", i / 60, i % 60)),
format!("## Summary\norig{i:04}-{body}\n\n## Next Steps\n{body}\n"),
)
.unwrap();
}
}
#[tokio::test]
async fn catchup_payload_bounds_an_oversized_store() {
let tmp = tempfile::TempDir::new().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let result = isolated_catchup(tmp.path().to_str().unwrap(), None, None, false, true, 0)
.await
.unwrap();
let fixture_record = 6_000 + 512;
let len = serde_json::to_string(&result).unwrap().len();
assert!(
len <= CATCHUP_BUDGET_BYTES + fixture_record,
"response is {len} chars against a {CATCHUP_BUDGET_BYTES}-byte budget"
);
assert_eq!(result["over_budget"], false);
assert!(
!result["sessions"].as_array().unwrap().is_empty(),
"a bound that returns nothing is not a bound: {result}"
);
}
#[tokio::test]
async fn catchup_payload_announces_what_it_withheld() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let first = isolated_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
assert_eq!(first["truncated"], true);
assert_eq!(first["sessions_total"], 25);
assert_eq!(first["sessions_offset"], 0);
let notice = first["truncation_notice"].as_str().unwrap().to_string();
assert!(notice.contains("25"), "names the total: {notice}");
let mut seen = first["sessions"].as_array().unwrap().len();
let mut next = first["sessions_next_offset"].as_u64();
assert!(
notice.contains(&format!("sessions_offset: {}", next.unwrap())),
"names the recovery: {notice}"
);
let mut pages = 1;
while let Some(offset) = next {
let body = isolated_catchup(dir, None, None, false, true, offset as usize)
.await
.unwrap();
assert_eq!(body["sessions_offset"], offset);
let n = body["sessions"].as_array().unwrap().len();
assert!(n > 0, "page at {offset} made no progress: {body}");
seen += n;
next = body["sessions_next_offset"].as_u64();
pages += 1;
assert!(pages < 50, "paging did not terminate");
}
assert!(pages > 1, "the fixture must actually need paging");
assert_eq!(seen, 25, "`full` must still deliver the whole history");
}
#[tokio::test]
async fn a_snapshot_written_mid_walk_repeats_a_record_but_never_drops_one() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
seed_snapshots(tmp.path(), 25, 6_000);
let first = isolated_catchup(dir, None, None, false, true, 0)
.await
.unwrap();
let page0: Vec<String> = summaries(&first);
let next = first["sessions_next_offset"].as_u64().unwrap() as usize;
std::fs::write(
tmp.path()
.join(".trusty-mpm")
.join("sessions")
.join("session-20260801-235959.md"),
format!("## Summary\ninjected-{}\n", "n".repeat(6_000)),
)
.unwrap();
let mut seen = page0.clone();
let mut offset = Some(next);
while let Some(o) = offset {
let body = isolated_catchup(dir, None, None, false, true, o)
.await
.unwrap();
seen.extend(summaries(&body));
offset = body["sessions_next_offset"].as_u64().map(|v| v as usize);
}
let originals: std::collections::HashSet<&String> =
seen.iter().filter(|s| s.starts_with("orig")).collect();
assert_eq!(
originals.len(),
25,
"an index shift must not drop a record; got {} distinct of 25",
originals.len()
);
assert!(
seen.len() > originals.len(),
"the shift is expected to repeat at least one record, so the \
disclosure describes something real"
);
}
#[tokio::test]
async fn catchup_payload_leaves_a_normal_store_whole() {
let tmp = tempfile::TempDir::new().unwrap();
seed_snapshots(tmp.path(), 3, 200);
let result = isolated_catchup(tmp.path().to_str().unwrap(), None, None, false, true, 0)
.await
.unwrap();
assert_eq!(result["sessions"].as_array().unwrap().len(), 3);
assert_eq!(result["sessions_total"], 3);
assert_eq!(result["truncated"], false);
assert!(result["truncation_notice"].is_null());
assert!(result["sessions_next_offset"].is_null());
}
#[tokio::test]
async fn session_context_catchup_withholds_a_non_owners_handles() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let session_a = "2eb72dca-de08-481b-8dfa-22ab7f81b1f9";
let session_b = "7bd5c27a-475b-41df-9e9f-a6f630801717";
let window = "tm-dogfood:0:@230";
let paused = isolated_pause(
&state,
dir,
Some(session_a),
"Session A's work.",
vec![],
vec!["halfway through X".to_string()],
vec!["finish X".to_string()],
Some(window),
false,
)
.await
.unwrap();
let a_snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let for_b = isolated_catchup(dir, Some(session_b), None, false, true, 0)
.await
.unwrap();
let listed = &for_b["sessions"][0];
assert!(
listed["source_file"].is_null(),
"B must not receive A's snapshot path: {listed}"
);
assert!(
listed["tmux_window"].is_null(),
"B must not receive A's window — that is the value it would hand back: {listed}"
);
assert_eq!(listed["owned"], false);
assert_eq!(listed["in_progress"], Value::Null);
assert_eq!(listed["next_steps"], Value::Null);
assert_eq!(listed["summary"], "Session A's work.");
let body = for_b.to_string();
assert!(
!body.contains("@230"),
"nothing in B's response may spell A's window id: {body}"
);
assert!(
!body.contains(&a_snapshot),
"nothing in B's response may spell A's snapshot path: {body}"
);
let for_a = isolated_catchup(dir, Some(session_a), None, false, true, 0)
.await
.unwrap();
let own = &for_a["sessions"][0];
assert_eq!(own["owned"], true);
assert_eq!(own["source_file"], a_snapshot);
assert_eq!(own["tmux_window"], window);
assert_eq!(own["next_steps"], "- finish X");
}
#[tokio::test]
async fn session_context_catchup_digest_agrees_with_the_window_fallback() {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().to_str().unwrap();
let state = DaemonState::shared();
let window = "tm-dogfood:0:@230";
let paused = isolated_pause(
&state,
dir,
Some("e262f4c5-d309-4203-ad3b-e0c29084d87e"),
"Work from the previous incarnation.",
vec![],
vec![],
vec![],
Some(window),
false,
)
.await
.unwrap();
let snapshot = paused["snapshot_path"].as_str().unwrap().to_string();
let relaunched = isolated_catchup(
dir,
Some("69895d04-149d-4c31-a640-29048831f9a5"),
Some(window),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(relaunched["resolved_snapshot"], snapshot);
assert_eq!(relaunched["resolved_via"], "tmux_window");
assert_eq!(
relaunched["sessions"][0]["owned"], true,
"the row resolved_snapshot points at must not be redacted: {}",
relaunched["sessions"][0]
);
assert_eq!(relaunched["sessions"][0]["source_file"], snapshot);
}
#[tokio::test]
async fn session_context_pause_missing_project_dir_errors() {
let state = DaemonState::shared();
let err = isolated_pause(
&state,
"/nonexistent/does/not/exist",
Some("s1"),
"summary",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("project_dir"), "{err}");
}
#[tokio::test]
async fn session_context_pause_requires_summary() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let err = isolated_pause(
&state,
tmp.path().to_str().unwrap(),
Some("s1"),
" ",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("summary"), "{err}");
}
#[tokio::test]
async fn session_context_pause_writes_snapshot_without_pruning() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let result = isolated_pause(
&state,
tmp.path().to_str().unwrap(),
Some("s1"),
"Did the thing.",
vec![],
vec![],
vec!["ship it".to_string()],
None,
false,
)
.await
.unwrap();
assert!(result["snapshot_path"].as_str().unwrap().ends_with(".md"));
assert_eq!(result["pruned_worktrees"], json!([]));
let sessions =
crate::core::catchup::session_finder::find_paused_sessions(tmp.path()).unwrap();
assert_eq!(sessions.len(), 1, "the written snapshot should round-trip");
}
#[tokio::test]
async fn a_failed_ref_publish_is_reported_and_never_fails_the_pause() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path();
for args in [
vec!["init", "--initial-branch=main"],
vec!["config", "user.email", "pause@example.invalid"],
vec!["config", "user.name", "Pause Tester"],
vec![
"remote",
"add",
"origin",
"/nonexistent/7830/no/such/remote.git",
],
] {
let out = trusty_common::git::command_in(repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "fixture `git {args:?}` failed");
}
let state = DaemonState::shared();
let result = session_context_pause_with(
&state,
repo.to_str().unwrap(),
Some("s-refs-fail"),
"Did the thing.",
vec![],
vec![],
vec![],
None,
false,
true,
)
.await
.expect("a ref publish failure must not fail the pause");
assert!(result["snapshot_path"].as_str().unwrap().ends_with(".md"));
assert_eq!(
result["ref_published"], false,
"an unreachable origin cannot have published a ref: {result}"
);
let err = result["ref_error"]
.as_str()
.expect("the failure must be reported, not swallowed");
assert!(!err.trim().is_empty(), "{result}");
let sessions = crate::core::catchup::session_finder::find_paused_sessions(repo).unwrap();
assert_eq!(
sessions.len(),
1,
"the local snapshot is still the primary write"
);
}
#[tokio::test]
async fn a_disabled_section_leaves_the_pause_exactly_as_it_was() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path();
for args in [
vec!["init", "--initial-branch=main"],
vec!["config", "user.email", "pause@example.invalid"],
vec!["config", "user.name", "Pause Tester"],
vec![
"remote",
"add",
"origin",
"/nonexistent/7830/no/such/remote.git",
],
] {
let out = trusty_common::git::command_in(repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "fixture `git {args:?}` failed");
}
let state = DaemonState::shared();
let result = session_context_pause_with(
&state,
repo.to_str().unwrap(),
Some("s-refs-off"),
"Did the thing.",
vec![],
vec![],
vec![],
None,
false,
false,
)
.await
.unwrap();
assert!(result["ref_name"].is_null(), "{result}");
assert_eq!(result["ref_published"], false, "{result}");
assert!(result["ref_error"].is_null(), "{result}");
let fetch = trusty_common::git::command_in(repo)
.args(["config", "--get-all", "remote.origin.fetch"])
.output()
.unwrap();
let configured = String::from_utf8_lossy(&fetch.stdout);
assert!(
!configured.contains(trusty_common::catchup::session_refs::SESSION_REF_FETCH_REFSPEC),
"a disabled section must not register the session fetch refspec: {configured}"
);
}
#[tokio::test]
async fn catchup_reports_a_hydration_failure_without_failing_the_catchup() {
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path();
for args in [
vec!["init", "--initial-branch=main"],
vec!["config", "user.email", "catchup@example.invalid"],
vec!["config", "user.name", "Catchup Tester"],
vec![
"remote",
"add",
"origin",
"/nonexistent/7830/no/such/remote.git",
],
] {
let out = trusty_common::git::command_in(repo)
.args(&args)
.output()
.unwrap();
assert!(out.status.success(), "fixture `git {args:?}` failed");
}
let receipt = hydrate_session_refs(repo, Some("s-hydrate-fail")).await;
assert!(!receipt.hydrated, "{receipt:?}");
assert_eq!(receipt.refs_seen, 0, "{receipt:?}");
let error = receipt
.error
.clone()
.expect("an unreachable origin must be reported, not swallowed");
assert!(!error.trim().is_empty(), "{receipt:?}");
let body = catchup_payload(
Default::default(),
0,
None,
None,
receipt,
SnapshotFreshness::default(),
);
assert_eq!(body["session_refs"]["hydrated"], false, "{body}");
assert_eq!(body["session_refs"]["refs_seen"], 0, "{body}");
assert_eq!(body["session_refs"]["own_ref_found"], false, "{body}");
assert_eq!(body["session_refs"]["restored"], 0, "{body}");
assert_eq!(body["session_refs"]["error"], error, "{body}");
let live = isolated_catchup(repo.to_str().unwrap(), None, None, false, true, 0)
.await
.expect("an unreachable remote must never fail a catch-up");
assert!(
live["session_refs"]["hydrated"].is_boolean(),
"the receipt must reach the wire: {live}"
);
}
#[tokio::test]
async fn catchup_hydrates_a_deleted_cache_from_the_session_ref() {
use crate::core::session_ref_publish::{SessionRefRequest, publish_receipt};
let tmp = tempfile::TempDir::new().unwrap();
let remote = tmp.path().join("remote.git");
let repo = tmp.path().join("work");
std::fs::create_dir_all(&repo).unwrap();
fn git_fixture(dir: &Path, args: &[&str]) {
let out = trusty_common::git::command_in(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"fixture `git {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
git_fixture(
tmp.path(),
&[
"init",
"--bare",
"--initial-branch=main",
remote.to_str().unwrap(),
],
);
git_fixture(&repo, &["init", "--initial-branch=main"]);
git_fixture(&repo, &["config", "user.email", "refs@example.invalid"]);
git_fixture(&repo, &["config", "user.name", "Ref Tester"]);
git_fixture(
&repo,
&["remote", "add", "origin", remote.to_str().unwrap()],
);
let state = DaemonState::shared();
let paused = isolated_pause(
&state,
repo.to_str().unwrap(),
Some("s-hydrate"),
"Parked mid-review.",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap();
let snapshot_path = PathBuf::from(paused["snapshot_path"].as_str().unwrap());
let receipt = publish_receipt(
&SessionRefRequest {
repo: &repo,
session_id: "s-hydrate",
snapshot_path: &snapshot_path,
timestamp: chrono::Utc::now(),
},
true,
);
assert!(receipt.published, "{receipt:?}");
std::fs::remove_dir_all(repo.join(".trusty-mpm/sessions")).unwrap();
assert!(!snapshot_path.exists());
let receipt = hydrate_session_refs(&repo, Some("s-hydrate")).await;
assert!(receipt.hydrated, "{receipt:?}");
assert_eq!(receipt.refs_seen, 1, "{receipt:?}");
assert!(receipt.own_ref_found, "{receipt:?}");
assert_eq!(receipt.restored, 1, "{receipt:?}");
let resumed = isolated_catchup(
repo.to_str().unwrap(),
Some("s-hydrate"),
None,
false,
true,
0,
)
.await
.unwrap();
assert_eq!(
resumed["resolved_snapshot"].as_str(),
Some(snapshot_path.to_string_lossy().as_ref()),
"the hydrated cache must resolve through the unchanged read path: {resumed}"
);
assert_eq!(resumed["resolved_via"], "session_id");
}
#[tokio::test]
async fn pause_derives_a_missing_session_id_from_the_callers_window() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let paused = isolated_pause(
&state,
tmp.path().to_str().unwrap(),
None,
"Did the thing.",
vec![],
vec![],
vec![],
Some("tm-dogfood:0:@230"),
false,
)
.await
.unwrap();
assert_eq!(
paused["session_id"], "tmux-window-230",
"the response must name the id the snapshot was filed under: {paused}"
);
let resumed = isolated_catchup(
tmp.path().to_str().unwrap(),
None,
Some("renamed:7:@230"),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(
resumed["resolved_snapshot"], paused["snapshot_path"],
"the resume must find the pause this caller wrote: {resumed}"
);
assert_eq!(
resumed["resolved_via"], "session_id",
"the DERIVED id must be what matched, not the window fallback: {resumed}"
);
}
#[tokio::test]
async fn pause_without_an_identifiable_caller_errors() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
let err = isolated_pause(
&state,
tmp.path().to_str().unwrap(),
None,
"Did the thing.",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap_err();
assert!(err.contains("session_id"), "{err}");
assert!(
crate::core::catchup::session_finder::find_paused_sessions(tmp.path())
.unwrap()
.is_empty(),
"a refused pause must write nothing"
);
}
#[tokio::test]
async fn catchup_derives_a_missing_session_id_from_the_callers_window() {
let tmp = tempfile::TempDir::new().unwrap();
let state = DaemonState::shared();
isolated_pause(
&state,
tmp.path().to_str().unwrap(),
Some("tmux-window-77"),
"Did the thing.",
vec![],
vec![],
vec![],
None,
false,
)
.await
.unwrap();
let resumed = isolated_catchup(
tmp.path().to_str().unwrap(),
None,
Some("proj:3:@77"),
false,
true,
0,
)
.await
.unwrap();
assert_eq!(resumed["resolved_via"], "session_id", "{resumed}");
}
}