use clap::Parser;
use crate::cli::{Cli, Command, SessionAction};
#[test]
fn cli_parses_session_new() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--git-ref",
"feat/x",
"--task",
"do the thing",
"--name-hint",
"ticket-1",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::New {
repo,
git_ref,
task,
name_hint,
runtime,
no_inject,
deliverable,
},
} => {
assert_eq!(repo, "https://github.com/owner/repo");
assert_eq!(git_ref, "feat/x");
assert_eq!(task, "do the thing");
assert_eq!(name_hint.as_deref(), Some("ticket-1"));
assert_eq!(runtime, trusty_mpm::runtime::RuntimeKind::ClaudeCode);
assert!(!no_inject);
assert_eq!(deliverable, None);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_with_deliverable() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--deliverable",
"11111111-1111-1111-1111-111111111111",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { deliverable, .. },
} => {
assert_eq!(
deliverable.as_deref(),
Some("11111111-1111-1111-1111-111111111111")
);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_no_inject() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--no-inject",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { no_inject, .. },
} => {
assert!(no_inject, "--no-inject must set the flag");
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_parses_session_new_with_tcode_runtime() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--runtime",
"tcode",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::New { runtime, .. },
} => {
assert_eq!(runtime, trusty_mpm::runtime::RuntimeKind::Tcode);
}
other => panic!("expected session new, got {other:?}"),
}
}
#[test]
fn cli_rejects_unknown_runtime_at_parse_time() {
let err = Cli::try_parse_from([
"trusty-mpm",
"session",
"new",
"https://github.com/owner/repo",
"--task",
"do the thing",
"--runtime",
"gpt",
])
.expect_err("unknown runtime must be rejected at parse time");
let msg = err.to_string();
assert!(
msg.contains("claude-code") && msg.contains("tcode"),
"error must list the supported runtimes: {msg}"
);
}
#[test]
fn cli_parses_session_ls() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--json"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Ls { json, .. },
} => assert!(json),
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_parses_session_ls_source_id() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--source-id", "myorg/myrepo"])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Ls {
source_id,
current,
json,
all,
..
},
} => {
assert_eq!(source_id, Some("myorg/myrepo".to_string()));
assert!(!current);
assert!(!json);
assert!(!all, "--all must default to false");
}
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_parses_session_ls_current() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--current"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Ls {
current,
source_id,
json,
all,
..
},
} => {
assert!(current);
assert!(source_id.is_none());
assert!(!json);
assert!(!all, "--all must default to false");
}
other => panic!("expected session ls, got {other:?}"),
}
}
#[test]
fn cli_session_ls_source_id_and_current_conflict() {
let result = Cli::try_parse_from([
"trusty-mpm",
"session",
"ls",
"--current",
"--source-id",
"foo/bar",
]);
assert!(
result.is_err(),
"passing both --current and --source-id must be a parse error"
);
}
#[test]
fn cli_parses_session_ls_all() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "ls", "--all"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Ls { all, json, .. },
} => {
assert!(all, "--all flag must parse to true");
assert!(!json);
}
other => panic!("expected session ls --all, got {other:?}"),
}
}
#[test]
fn cli_parses_session_activity() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "activity", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Activity { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session activity, got {other:?}"),
}
}
#[test]
fn cli_parses_session_send() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "send", "abc", "hello"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Send { id, text },
} => {
assert_eq!(id, "abc");
assert_eq!(text, "hello");
}
other => panic!("expected session send, got {other:?}"),
}
}
#[test]
fn cli_parses_session_answer() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "answer", "abc", "rebase"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Answer { id, answer },
} => {
assert_eq!(id, "abc");
assert_eq!(answer, "rebase");
}
other => panic!("expected session answer, got {other:?}"),
}
}
#[test]
fn cli_parses_session_attach() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "attach", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Attach { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session attach, got {other:?}"),
}
}
#[test]
fn cli_parses_session_managed_stop() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "managed-stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ManagedStop { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session managed-stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_runtime_stop() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "runtime-stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::RuntimeStop { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session runtime-stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_managed_resume() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "managed-resume", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ManagedResume { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session managed-resume, got {other:?}"),
}
}
#[test]
fn cli_parses_session_stop_verb() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "stop", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Stop { id_or_name },
} => assert_eq!(id_or_name, "abc"),
other => panic!("expected session stop, got {other:?}"),
}
}
#[test]
fn cli_parses_session_resume_verb() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "resume", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Resume { id_or_name },
} => assert_eq!(id_or_name, "abc"),
other => panic!("expected session resume, got {other:?}"),
}
}
#[test]
fn cli_parses_session_decommission() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Decommission { id },
} => assert_eq!(id, "abc"),
other => panic!("expected session decommission, got {other:?}"),
}
}
#[test]
fn cli_parses_session_delete() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "delete", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Delete { id, force },
} => {
assert_eq!(id, "abc");
assert!(!force, "default must be the fail-closed force=false");
}
other => panic!("expected session delete, got {other:?}"),
}
let cli2 = Cli::try_parse_from(["trusty-mpm", "session", "delete", "abc", "--force"]).unwrap();
match cli2.command.unwrap() {
Command::Session {
action: SessionAction::Delete { id, force },
} => {
assert_eq!(id, "abc");
assert!(force, "--force must set force=true");
}
other => panic!("expected session delete, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_rename_two_args() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "rename", "tm-old-01", "tm-new-01"])
.unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Rename { arg1, arg2 },
} => {
assert_eq!(arg1, "tm-old-01");
assert_eq!(arg2.as_deref(), Some("tm-new-01"));
}
other => panic!("expected sessions rename, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_rename_in_session() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "rename", "tm-new-name"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Rename { arg1, arg2 },
} => {
assert_eq!(arg1, "tm-new-name");
assert_eq!(arg2, None);
}
other => panic!("expected sessions rename, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune_idle() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-idle", "--dry-run", "--json"])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::PruneIdle { dry_run, json },
} => {
assert!(dry_run);
assert!(json);
}
other => panic!("expected session prune-idle, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune_idle_defaults() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-idle"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::PruneIdle { dry_run, json },
} => {
assert!(!dry_run);
assert!(!json);
}
other => panic!("expected session prune-idle, got {other:?}"),
}
}
#[test]
fn cli_parses_session_decommission_ephemeral() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "decommission-ephemeral"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::DecommissionEphemeral,
} => {}
other => panic!("expected session decommission-ephemeral, got {other:?}"),
}
}
#[test]
fn cli_parses_session_prune() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune",
"--state",
"stopped",
"--dry-run",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::Prune {
state,
dry_run,
include_active,
},
} => {
assert_eq!(state, "stopped");
assert!(dry_run);
assert!(
!include_active,
"include_active defaults to the fail-closed false"
);
}
other => panic!("expected session prune, got {other:?}"),
}
}
#[test]
fn cli_session_prune_requires_state() {
let err = Cli::try_parse_from(["trusty-mpm", "session", "prune"]);
assert!(err.is_err(), "prune without --state must fail to parse");
}
#[test]
fn cli_parses_session_prune_worktrees() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "prune-worktrees"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(!force, "default must be dry-run (force=false)");
assert!(!discard_dirty, "default must never discard dirty work");
assert!(
!merged_prs,
"#2919: the merged-PR pass must be off by default"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
let cli2 =
Cli::try_parse_from(["trusty-mpm", "session", "prune-worktrees", "--force"]).unwrap();
match cli2.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force, "--force must set force=true");
assert!(
!discard_dirty,
"#4091: --force alone must NOT imply discarding uncommitted work"
);
assert!(
!merged_prs,
"#2919: --force alone must NOT imply the merged-PR reclaim pass"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_prune_worktrees_discard_dirty_is_opt_in() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune-worktrees",
"--force",
"--discard-dirty",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force);
assert!(discard_dirty, "--discard-dirty must set discard_dirty=true");
assert!(
!merged_prs,
"#2919: --discard-dirty must NOT imply the merged-PR reclaim pass"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_prune_worktrees_merged_prs_is_opt_in() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"prune-worktrees",
"--force",
"--merged-prs",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action:
SessionAction::PruneWorktrees {
force,
discard_dirty,
merged_prs,
},
} => {
assert!(force);
assert!(merged_prs, "--merged-prs must set merged_prs=true");
assert!(
!discard_dirty,
"#2919: --merged-prs must NOT imply discarding uncommitted work"
);
}
other => panic!("expected session prune-worktrees, got {other:?}"),
}
}
#[test]
fn cli_parses_session_reconcile_worktrees() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::ReconcileWorktrees { json },
} => assert!(!json, "the default must be the readable report, not JSON"),
other => panic!("expected session reconcile-worktrees, got {other:?}"),
}
let as_json =
Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees", "--json"]).unwrap();
match as_json.command.unwrap() {
Command::Session {
action: SessionAction::ReconcileWorktrees { json },
} => assert!(json),
other => panic!("expected session reconcile-worktrees, got {other:?}"),
}
}
#[test]
fn cli_reconcile_worktrees_takes_no_destructive_flag() {
for flag in ["--force", "--discard-dirty", "--dry-run"] {
assert!(
Cli::try_parse_from(["trusty-mpm", "session", "reconcile-worktrees", flag]).is_err(),
"`reconcile-worktrees {flag}` must not parse — slice 3 writes nothing"
);
}
}
#[test]
fn cli_parses_sessions_sync_assets() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "abc"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::SyncAssets { id, all },
} => {
assert_eq!(id.as_deref(), Some("abc"));
assert!(!all);
}
other => panic!("expected sessions sync-assets, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_sync_assets_all() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "--all"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::SyncAssets { id, all },
} => {
assert_eq!(id, None);
assert!(all);
}
other => panic!("expected sessions sync-assets --all, got {other:?}"),
}
}
#[test]
fn cli_sessions_sync_assets_requires_id_or_all() {
let err = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets"]);
assert!(
err.is_err(),
"sync-assets with neither an id nor --all must fail to parse"
);
}
#[test]
fn cli_sessions_sync_assets_id_and_all_conflict() {
let err = Cli::try_parse_from(["trusty-mpm", "sessions", "sync-assets", "abc", "--all"]);
assert!(
err.is_err(),
"sync-assets with both an id and --all must fail to parse"
);
}
#[test]
fn cli_parses_session_catchup() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"session",
"catchup",
"--all-projects",
"--full",
])
.unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Catchup { all_projects, full },
} => {
assert!(all_projects, "--all-projects should be true");
assert!(full, "--full should be true");
}
other => panic!("expected session catchup, got {other:?}"),
}
}
#[test]
fn cli_parses_session_catchup_defaults() {
let cli = Cli::try_parse_from(["trusty-mpm", "session", "catchup"]).unwrap();
match cli.command.unwrap() {
Command::Session {
action: SessionAction::Catchup { all_projects, full },
} => {
assert!(!all_projects, "--all-projects should default to false");
assert!(!full, "--full should default to false");
}
other => panic!("expected session catchup, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_connector_bare() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
terms,
projects,
json,
source_id,
current,
all,
root,
} => {
assert!(
terms.is_empty(),
"bare `tm ls` must have no positional terms"
);
assert!(!projects, "bare `tm ls` must not set --projects");
assert!(!json);
assert!(source_id.is_none());
assert!(!current);
assert!(!all);
assert!(root.is_none());
}
other => panic!("expected top-level ls, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_projects() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--projects"]).unwrap();
match cli.command.unwrap() {
Command::Ls { projects, .. } => assert!(projects, "--projects must parse to true"),
other => panic!("expected top-level ls --projects, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_projects_short() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "-p"]).unwrap();
match cli.command.unwrap() {
Command::Ls { projects, .. } => assert!(projects, "-p must parse to true"),
other => panic!("expected top-level ls -p, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_json() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--json"]).unwrap();
match cli.command.unwrap() {
Command::Ls { json, projects, .. } => {
assert!(json, "--json must parse to true");
assert!(!projects, "--json alone must not imply --projects");
}
other => panic!("expected top-level ls --json, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_current() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--current"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
current, source_id, ..
} => {
assert!(current, "--current must parse to true");
assert!(source_id.is_none());
}
other => panic!("expected top-level ls --current, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_source_id() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "--source-id", "owner/repo"]).unwrap();
match cli.command.unwrap() {
Command::Ls {
source_id, current, ..
} => {
assert_eq!(source_id, Some("owner/repo".to_string()));
assert!(!current);
}
other => panic!("expected top-level ls --source-id, got {other:?}"),
}
}
#[test]
fn cli_ls_source_id_and_current_conflict() {
let result =
Cli::try_parse_from(["trusty-mpm", "ls", "--current", "--source-id", "owner/repo"]);
assert!(
result.is_err(),
"passing both --current and --source-id to `tm ls` must be a parse error"
);
}
use crate::commands::session_picker::should_show_picker;
#[test]
fn ls_connector_should_show_picker_interactive_with_sessions() {
assert!(should_show_picker(true, true, false, false, 1));
assert!(should_show_picker(true, true, false, false, 5));
}
#[test]
fn ls_connector_should_show_picker_non_tty_static() {
assert!(
!should_show_picker(false, true, false, false, 3),
"piped stdin -> static"
);
assert!(
!should_show_picker(true, false, false, false, 3),
"piped stdout -> static"
);
assert!(!should_show_picker(false, false, false, false, 3));
}
#[test]
fn ls_connector_should_show_picker_flags_and_empty_static() {
assert!(
!should_show_picker(true, true, true, false, 3),
"--json -> static"
);
assert!(
!should_show_picker(true, true, false, true, 3),
"--all -> static"
);
assert!(
!should_show_picker(true, true, false, false, 0),
"0 sessions -> static"
);
}
use crate::commands::session_picker::{
SessionFilter, SessionSortArg, filter_sessions_by_term, parse_ls_terms, sort_sessions,
};
#[test]
fn parse_ls_terms_empty_defaults_recent_no_filter() {
assert_eq!(parse_ls_terms(&[]), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_recent_keyword_only() {
let terms = vec!["recent".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_alpha_keyword_only() {
let terms = vec!["alpha".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Alpha, None));
}
#[test]
fn parse_ls_terms_keyword_case_insensitive() {
let terms = vec!["ALPHA".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Alpha, None));
let terms = vec!["Recent".to_string()];
assert_eq!(parse_ls_terms(&terms), (SessionSortArg::Recent, None));
}
#[test]
fn parse_ls_terms_recent_with_filter() {
let terms = vec!["recent".to_string(), "api".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("api".to_string()))
);
}
#[test]
fn parse_ls_terms_alpha_with_filter() {
let terms = vec!["alpha".to_string(), "api".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Alpha, Some("api".to_string()))
);
}
#[test]
fn parse_ls_terms_non_keyword_first_word_is_filter() {
let terms = vec!["foo".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("foo".to_string()))
);
}
#[test]
fn parse_ls_terms_multi_word_filter_without_keyword_joins_with_space() {
let terms = vec!["foo".to_string(), "bar".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("foo bar".to_string()))
);
}
#[test]
fn parse_ls_terms_keyword_then_filter_equal_to_other_keyword() {
let terms = vec!["recent".to_string(), "alpha".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Recent, Some("alpha".to_string()))
);
let terms = vec!["alpha".to_string(), "recent".to_string()];
assert_eq!(
parse_ls_terms(&terms),
(SessionSortArg::Alpha, Some("recent".to_string()))
);
}
fn ls_test_session(
name: &str,
state: &str,
last_activity_at: Option<&str>,
created_at: Option<&str>,
source_id: Option<&str>,
task: Option<&str>,
) -> trusty_mpm::client::ManagedSessionSummary {
trusty_mpm::client::ManagedSessionSummary {
id: format!("{name}-id"),
name: name.to_string(),
state: state.to_string(),
persisted_state: None,
workspace_path: None,
repo_url: None,
branch: None,
created_at: created_at.map(str::to_owned),
last_activity_at: last_activity_at.map(str::to_owned),
pending_decision: None,
proposed_default: None,
source_id: source_id.map(str::to_owned),
task: task.map(str::to_owned),
cwd: None,
claude_session_id: None,
deliverable_id: None,
pane_id: None,
injection_status: None,
unresumable: false,
stale_assets: false,
stale_assets_unchecked: false,
attached: false,
slot: 0,
deleted: false,
}
}
#[test]
fn filter_sessions_by_term_matches_name() {
let sessions = vec![
ls_test_session("api-worker", "active", None, None, None, None),
ls_test_session("web-frontend", "active", None, None, None, None),
];
let out = filter_sessions_by_term(sessions, Some(&SessionFilter::visible("API")));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "api-worker");
}
#[test]
fn filter_sessions_by_term_matches_task() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, Some("fix the login bug")),
ls_test_session("s2", "active", None, None, None, Some("write docs")),
];
let out = filter_sessions_by_term(sessions, Some(&SessionFilter::visible("login")));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "s1");
}
#[test]
fn filter_sessions_by_term_matches_source_id() {
let sessions = vec![
ls_test_session(
"s1",
"active",
None,
None,
Some("bobmatnyc/trusty-tools"),
None,
),
ls_test_session("s2", "active", None, None, Some("other/repo"), None),
];
let out = filter_sessions_by_term(sessions, Some(&SessionFilter::visible("trusty-tools")));
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "s1");
}
#[test]
fn filter_sessions_by_term_is_case_insensitive() {
let sessions = vec![ls_test_session("MyApp", "ACTIVE", None, None, None, None)];
assert_eq!(
filter_sessions_by_term(sessions.clone(), Some(&SessionFilter::visible("myapp"))).len(),
1
);
assert_eq!(
filter_sessions_by_term(sessions, Some(&SessionFilter::visible("active"))).len(),
1
);
}
#[test]
fn filter_sessions_by_term_no_match_returns_empty() {
let sessions = vec![ls_test_session("s1", "active", None, None, None, None)];
let out = filter_sessions_by_term(
sessions,
Some(&SessionFilter::visible("nonexistent-substring")),
);
assert!(out.is_empty(), "no match must yield an empty result");
}
#[test]
fn filter_sessions_by_term_none_is_noop() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "stopped", None, None, None, None),
];
assert_eq!(filter_sessions_by_term(sessions, None).len(), 2);
}
#[test]
fn sort_sessions_recent_orders_by_last_activity() {
let mut sessions = vec![
ls_test_session(
"older",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"newest",
"active",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"middle",
"active",
Some("2026-04-01T00:00:00Z"),
None,
None,
None,
),
];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["newest", "middle", "older"]);
}
#[test]
fn sort_sessions_recent_falls_back_to_created_at() {
let mut sessions = vec![
ls_test_session("no-timestamp", "active", None, None, None, None),
ls_test_session(
"created-only",
"active",
None,
Some("2026-03-01T00:00:00Z"),
None,
None,
),
ls_test_session(
"has-activity",
"active",
Some("2026-06-01T00:00:00Z"),
Some("2026-01-01T00:00:00Z"),
None,
None,
),
];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["has-activity", "created-only", "no-timestamp"]);
}
#[test]
fn sort_sessions_alpha_orders_by_name_case_insensitive() {
let mut sessions = vec![
ls_test_session("Zebra", "active", None, None, None, None),
ls_test_session("apple", "active", None, None, None, None),
ls_test_session("Mango", "active", None, None, None, None),
];
sort_sessions(&mut sessions, SessionSortArg::Alpha);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["apple", "Mango", "Zebra"]);
}
#[test]
fn sort_sessions_recent_groups_attached_before_active_before_stopped() {
let mut stopped_but_newest = ls_test_session(
"stopped-newest",
"stopped",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
);
stopped_but_newest.attached = false;
let mut active_but_older = ls_test_session(
"active-older",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
);
active_but_older.attached = false;
let mut attached_but_oldest = ls_test_session(
"attached-oldest",
"active",
Some("2025-01-01T00:00:00Z"),
None,
None,
None,
);
attached_but_oldest.attached = true;
let mut sessions = vec![stopped_but_newest, active_but_older, attached_but_oldest];
sort_sessions(&mut sessions, SessionSortArg::Recent);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["attached-oldest", "active-older", "stopped-newest"],
"attached must lead regardless of recency, active must lead over \
stopped regardless of recency"
);
}
#[test]
fn sort_sessions_alpha_groups_attached_before_active_before_stopped() {
let mut stopped_but_a = ls_test_session("aaa-stopped", "stopped", None, None, None, None);
stopped_but_a.attached = false;
let mut active_but_m = ls_test_session("mmm-active", "active", None, None, None, None);
active_but_m.attached = false;
let mut attached_but_z = ls_test_session("zzz-attached", "active", None, None, None, None);
attached_but_z.attached = true;
let mut sessions = vec![stopped_but_a, active_but_m, attached_but_z];
sort_sessions(&mut sessions, SessionSortArg::Alpha);
let names: Vec<&str> = sessions.iter().map(|s| s.name.as_str()).collect();
assert_eq!(
names,
vec!["zzz-attached", "mmm-active", "aaa-stopped"],
"attached must lead regardless of name, active must lead over \
stopped regardless of name"
);
}
#[test]
fn filter_and_sort_combined() {
let sessions = vec![
ls_test_session(
"api-old",
"active",
Some("2026-01-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"web-new",
"active",
Some("2026-07-01T00:00:00Z"),
None,
None,
None,
),
ls_test_session(
"api-new",
"active",
Some("2026-06-01T00:00:00Z"),
None,
None,
None,
),
];
let mut filtered = filter_sessions_by_term(sessions, Some(&SessionFilter::visible("api")));
assert_eq!(
filtered.len(),
2,
"only the two 'api' sessions survive the filter"
);
sort_sessions(&mut filtered, SessionSortArg::Recent);
let names: Vec<&str> = filtered.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["api-new", "api-old"]);
}
use crate::commands::session_picker::{find_slot, shown_slot, slots_are_stale};
#[test]
fn slots_are_stale_true_when_all_zero() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert!(slots_are_stale(&sessions));
}
#[test]
fn slots_are_stale_false_when_any_nonzero() {
let mut sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
];
sessions[1].slot = 2;
assert!(!slots_are_stale(&sessions));
}
#[test]
fn slots_are_stale_false_when_empty() {
assert!(!slots_are_stale(&[]));
}
#[test]
fn find_slot_healthy_looks_up_by_real_slot() {
let mut sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
];
sessions[0].slot = 1;
sessions[1].slot = 5;
assert_eq!(find_slot(&sessions, 5), Some(1));
assert_eq!(find_slot(&sessions, 2), None);
}
#[test]
fn find_slot_stale_fallback_resolves_positionally() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert_eq!(find_slot(&sessions, 1), Some(0));
assert_eq!(find_slot(&sessions, 2), Some(1));
assert_eq!(find_slot(&sessions, 3), Some(2));
assert_eq!(find_slot(&sessions, 4), None);
assert_eq!(find_slot(&sessions, 0), None);
}
#[test]
fn shown_slot_healthy_uses_real_slot() {
let mut sessions = vec![ls_test_session("s1", "active", None, None, None, None)];
sessions[0].slot = 7;
assert_eq!(shown_slot(&sessions, 0), 7);
}
#[test]
fn shown_slot_stale_fallback_is_distinct_and_incrementing() {
let sessions = vec![
ls_test_session("s1", "active", None, None, None, None),
ls_test_session("s2", "active", None, None, None, None),
ls_test_session("s3", "active", None, None, None, None),
];
assert_eq!(shown_slot(&sessions, 0), 1);
assert_eq!(shown_slot(&sessions, 1), 2);
assert_eq!(shown_slot(&sessions, 2), 3);
}
#[test]
fn cli_parses_ls_terms_recent_keyword() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "recent"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => assert_eq!(terms, vec!["recent".to_string()]),
other => panic!("expected top-level ls recent, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_alpha_with_filter() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "alpha", "foo"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => {
assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()])
}
other => panic!("expected top-level ls alpha foo, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_bare_filter_word() {
let cli = Cli::try_parse_from(["trusty-mpm", "ls", "somefilter"]).unwrap();
match cli.command.unwrap() {
Command::Ls { terms, .. } => assert_eq!(terms, vec!["somefilter".to_string()]),
other => panic!("expected top-level ls somefilter, got {other:?}"),
}
}
#[test]
fn cli_parses_ls_terms_with_source_id_flag() {
let cli = Cli::try_parse_from([
"trusty-mpm",
"ls",
"--source-id",
"owner/repo",
"alpha",
"foo",
])
.unwrap();
match cli.command.unwrap() {
Command::Ls {
terms, source_id, ..
} => {
assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()]);
assert_eq!(source_id, Some("owner/repo".to_string()));
}
other => panic!("expected top-level ls with source-id + terms, got {other:?}"),
}
}
#[test]
fn cli_parses_sessions_ls_terms_alpha_with_filter() {
let cli = Cli::try_parse_from(["trusty-mpm", "sessions", "ls", "alpha", "foo"]).unwrap();
match cli.command.unwrap() {
Command::Sessions {
action: SessionAction::Ls { terms, .. },
} => assert_eq!(terms, vec!["alpha".to_string(), "foo".to_string()]),
other => panic!("expected sessions ls alpha foo, got {other:?}"),
}
}
use crate::commands::picker_delete::{
ManagedDeleteNext, classify_managed_delete, confirm_is_force, confirm_is_yes,
delete_needs_force,
};
#[test]
fn delete_needs_force_running_true() {
assert!(delete_needs_force("active"));
assert!(delete_needs_force("provisioning"));
}
#[test]
fn delete_needs_force_stopped_and_errored_false() {
assert!(!delete_needs_force("stopped"));
assert!(!delete_needs_force("errored"));
}
#[test]
fn classify_managed_delete_maps_each_family() {
assert_eq!(
classify_managed_delete(reqwest::StatusCode::OK),
ManagedDeleteNext::Deleted
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::NOT_FOUND),
ManagedDeleteNext::FallbackLocal,
"404 -> project-session fallback (the other family)"
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::CONFLICT),
ManagedDeleteNext::Refused,
"409 -> running guard, never auto-force"
);
assert_eq!(
classify_managed_delete(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
ManagedDeleteNext::Error
);
}
#[test]
fn confirm_is_yes_accepts_only_y_variants() {
assert!(confirm_is_yes("y"));
assert!(confirm_is_yes("Y"));
assert!(confirm_is_yes(" yes \n"));
assert!(!confirm_is_yes(""));
assert!(!confirm_is_yes("force"));
assert!(!confirm_is_yes("delete"));
}
#[test]
fn confirm_is_force_requires_the_word_force() {
assert!(confirm_is_force("force"));
assert!(confirm_is_force(" FORCE \n"));
assert!(!confirm_is_force("y"));
assert!(!confirm_is_force("yes"));
assert!(!confirm_is_force(""));
}
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use trusty_mpm::core::session::{ControlModel, Session, SessionId, SessionStatus};
use trusty_mpm::daemon::state::DaemonState;
use crate::commands::picker_delete::{
DeleteReport, delete_managed_then_local, local_session_needs_force,
};
struct LocalTestServer {
url: String,
handle: tokio::task::JoinHandle<()>,
}
impl LocalTestServer {
async fn spawn(state: std::sync::Arc<DaemonState>) -> Self {
let app = trusty_mpm::daemon::api::router(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr: SocketAddr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
Self::wait_for_health(&url).await;
Self { url, handle }
}
async fn wait_for_health(base: &str) {
let client = reqwest::Client::new();
let deadline = Instant::now() + Duration::from_secs(2);
let health = format!("{base}/health");
loop {
if let Ok(resp) = client.get(&health).send().await
&& resp.status().is_success()
{
return;
}
if Instant::now() >= deadline {
panic!("daemon at {base} did not become healthy within 2s");
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
}
impl Drop for LocalTestServer {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn spawn_with_local_session(status: SessionStatus) -> (LocalTestServer, String) {
let state = DaemonState::shared();
let id = SessionId::new();
let mut session = Session::new(id, "/tmp/2304-fixture", ControlModel::Tmux, None);
session.status = status;
state.register_session(session);
let server = LocalTestServer::spawn(state).await;
(server, id.0.to_string())
}
#[tokio::test]
async fn local_delete_refuses_running_session_without_force() {
let (server, id) = spawn_with_local_session(SessionStatus::Active).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, false)
.await
.expect("routing call must not error");
assert!(
matches!(report, DeleteReport::Refused(_)),
"running local session without --force must be Refused, got {report:?}"
);
let still_there = client
.get(format!("{}/sessions/{id}", server.url))
.send()
.await
.expect("get session")
.status();
assert_eq!(
still_there,
reqwest::StatusCode::OK,
"session must NOT have been deleted"
);
}
#[tokio::test]
async fn local_delete_allows_stopped_session_without_force() {
let (server, id) = spawn_with_local_session(SessionStatus::Stopped).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, false)
.await
.expect("routing call must not error");
match report {
DeleteReport::Deleted { local, .. } => assert!(local, "must report the local fallback"),
other => panic!("expected Deleted, got {other:?}"),
}
let gone = client
.get(format!("{}/sessions/{id}", server.url))
.send()
.await
.expect("get session")
.status();
assert_eq!(gone, reqwest::StatusCode::NOT_FOUND, "session must be gone");
}
#[tokio::test]
async fn local_delete_force_bypasses_guard_on_running_session() {
let (server, id) = spawn_with_local_session(SessionStatus::Active).await;
let client = reqwest::Client::new();
let report = delete_managed_then_local(&client, &server.url, &id, true)
.await
.expect("routing call must not error");
match report {
DeleteReport::Deleted { local, .. } => assert!(local),
other => panic!("expected Deleted with force=true, got {other:?}"),
}
}
#[test]
fn local_session_needs_force_stopped_false() {
assert!(!local_session_needs_force(SessionStatus::Stopped));
}
#[test]
fn local_session_needs_force_active_and_others_true() {
assert!(local_session_needs_force(SessionStatus::Starting));
assert!(local_session_needs_force(SessionStatus::Active));
assert!(local_session_needs_force(SessionStatus::AwaitingApproval));
assert!(local_session_needs_force(SessionStatus::Detached));
assert!(local_session_needs_force(SessionStatus::Paused));
}
use std::collections::HashSet;
use crate::commands::session_picker_prune::auto_prune_dead_records_at;
async fn seed_dead_session(
mgr: &trusty_mpm::session_manager::SessionManager,
root: &std::path::Path,
label: &str,
) -> trusty_mpm::session_manager::ManagedSessionId {
let gone = root.join(format!("gone-workspace-{label}"));
let id = trusty_mpm::session_manager::ManagedSessionId::new();
mgr.create_with_id(
id,
format!("regression: auto-prune dead record {label}"),
Some(gone.clone()),
None,
Some(gone),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
trusty_mpm::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed dead session");
mgr.mark_errored(&id, "regression: simulate prior spawn failure")
.await
.expect("mark errored");
id
}
fn hermetic_prune_ctx(
marker_path: &std::path::Path,
) -> crate::commands::session_picker_prune::PruneContext {
crate::commands::session_picker_prune::PruneContext::hermetic(
marker_path.to_path_buf(),
Some(HashSet::new()),
)
}
fn backdate_sightings(marker_path: &std::path::Path, minutes: i64) {
let backdated = (chrono::Utc::now() - chrono::Duration::minutes(minutes)).to_rfc3339();
rewrite_sightings(marker_path, |_| backdated.clone());
}
fn age_sightings_by(marker_path: &std::path::Path, minutes: i64) {
rewrite_sightings(marker_path, |current| {
let parsed = chrono::DateTime::parse_from_rfc3339(current)
.expect("a persisted sighting must be valid RFC 3339 to age it");
(parsed - chrono::Duration::minutes(minutes)).to_rfc3339()
});
}
fn rewrite_sightings(marker_path: &std::path::Path, f: impl Fn(&str) -> String) {
let raw = std::fs::read_to_string(marker_path).expect("marker file must exist");
let mut seen: std::collections::HashMap<String, String> =
serde_json::from_str(&raw).expect("marker file must parse");
for (key, value) in seen.iter_mut() {
if key != "__stale_daemon_detected__" {
*value = f(value);
}
}
std::fs::write(marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
}
async fn fetch_raw_live(
client: &reqwest::Client,
url: &str,
) -> Vec<trusty_mpm::client::ManagedSessionSummary> {
let raw = crate::commands::session_picker::fetch_managed_raw(client, url, None)
.await
.expect("fetch raw");
crate::commands::session_picker::parse_managed_sessions(&raw).expect("parse")
}
fn default_view_pre_4994(
sessions: Vec<trusty_mpm::client::ManagedSessionSummary>,
) -> Vec<trusty_mpm::client::ManagedSessionSummary> {
crate::commands::session_picker::scope_for_display(
sessions,
false,
&std::collections::HashSet::new(),
)
}
#[tokio::test]
async fn auto_prune_dead_records_first_sighting_is_not_pruned() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "first").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let target = sessions
.iter()
.find(|s| s.id == id.to_string())
.expect("dead session must still surface on the raw fetch");
assert!(target.unresumable, "seeded session must be unresumable");
let outcome = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 0, "a first sighting must never be pruned");
assert_eq!(outcome.pending, 1, "a first sighting is reported pending");
assert!(
outcome.kept.iter().any(|s| s.id == id.to_string()),
"the record must remain visible while awaiting confirmation"
);
let still_there = fetch_raw_live(&client, &server.url).await;
let record = still_there
.iter()
.find(|s| s.id == id.to_string())
.expect("record must still be live");
assert_eq!(record.state, "errored");
}
#[tokio::test]
async fn auto_prune_dead_records_removes_confirmed_unresumable_records() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "confirmed").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let first = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(first.pruned, 0);
let sessions = fetch_raw_live(&client, &server.url).await;
let immediate = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
immediate.pruned, 0,
"a second call in the same instant must not confirm — the window is elapsed TIME"
);
backdate_sightings(&marker_path, 11);
let sessions = fetch_raw_live(&client, &server.url).await;
let second = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
second.pruned, 1,
"confirmed on a second listing must be pruned"
);
assert_eq!(second.pending, 0);
assert!(
!second.kept.iter().any(|s| s.id == id.to_string()),
"the pruned record must not remain in the returned list"
);
let refetched = default_view_pre_4994(fetch_raw_live(&client, &server.url).await);
assert!(
!refetched.iter().any(|s| s.id == id.to_string()),
"the decommissioned record must no longer appear in the default listing"
);
}
#[tokio::test]
async fn auto_prune_dead_records_keeps_workspace_present_records() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let ws = root.path().join("still-here-workspace");
std::fs::create_dir_all(&ws).expect("create real workspace dir");
let id = trusty_mpm::session_manager::ManagedSessionId::new();
mgr.create_with_id(
id,
"regression: auto-prune keeps live workspace".to_string(),
Some(ws.clone()),
None,
Some(ws),
Some("https://example.com/r.git".to_string()),
Some("main".to_string()),
trusty_mpm::runtime::RuntimeKind::default(),
false,
false,
)
.await
.expect("seed session with real workspace");
mgr.stop(&id)
.await
.expect("stop session (workspace intact)");
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
let target = sessions
.iter()
.find(|s| s.id == id.to_string())
.expect("stopped session with a real workspace must surface");
assert!(
!target.unresumable,
"a stopped session whose workspace still exists on disk must never \
be flagged unresumable"
);
let outcome = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"a record with an existing workspace must never be pruned"
);
assert_eq!(outcome.pending, 0);
assert!(
outcome.kept.iter().any(|s| s.id == id.to_string()),
"the record must survive auto-prune untouched"
);
}
#[tokio::test]
async fn auto_prune_dead_records_is_noop_when_nothing_is_dead() {
let client = reqwest::Client::new();
let marker_path = tempfile::TempDir::new()
.expect("tempdir")
.path()
.join("seen.json");
let sessions = vec![ls_test_session("healthy", "active", None, None, None, None)];
let outcome = auto_prune_dead_records_at(
&client,
"http://127.0.0.1:1",
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 0);
assert_eq!(outcome.pending, 0);
assert_eq!(outcome.kept.len(), 1);
assert_eq!(outcome.kept[0].name, "healthy");
}
#[tokio::test]
async fn auto_prune_dead_records_honors_the_cap() {
let root = tempfile::TempDir::new().expect("tempdir");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
for i in 0..6 {
seed_dead_session(&mgr, root.path(), &format!("cap-{i}")).await;
}
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let marker_path = root.path().join("seen.json");
let sessions = fetch_raw_live(&client, &server.url).await;
assert_eq!(sessions.len(), 6);
let first = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(first.pruned, 0);
assert_eq!(first.pending, 6);
backdate_sightings(&marker_path, 11);
let sessions = fetch_raw_live(&client, &server.url).await;
assert_eq!(sessions.len(), 6);
let second = auto_prune_dead_records_at(
&client,
&server.url,
sessions,
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(second.pruned, 5, "the cap must limit a single call to 5");
assert_eq!(
second.pending, 1,
"the 6th confirmed record must be reported pending, not silently dropped"
);
assert_eq!(second.kept.len(), 1);
}
#[tokio::test]
async fn auto_prune_dead_records_stops_sweep_when_daemon_reports_workspace_removed() {
async fn stub_decommission(
axum::extract::Path(_id): axum::extract::Path<String>,
) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "workspace_removed": true }))
}
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(stub_decommission),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert("id-b".to_string(), "2020-01-01T00:00:00Z".to_string());
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let mut a = stopped_session_at("id-a", &root.path().join("gone-a"));
a.state = "errored".to_string();
a.unresumable = true;
let mut b = stopped_session_at("id-b", &root.path().join("gone-b"));
b.state = "errored".to_string();
b.unresumable = true;
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![a, b],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"a stale daemon's workspace_removed=true must never count as pruned"
);
assert_eq!(
outcome.kept.len(),
2,
"both records must be left on the manual [d<N>] path once the stale \
daemon is detected"
);
assert_eq!(
outcome.pending, 2,
"both stale-daemon-held records must fold into `pending`, not vanish \
from the reported count"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_dead_records_stale_daemon_sentinel_expires_after_ttl() {
async fn stub_decommission(
axum::extract::Path(_id): axum::extract::Path<String>,
) -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "workspace_removed": true }))
}
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(stub_decommission),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone-ttl");
let confirmed_session = || {
let mut s = stopped_session_at("id-a", &gone);
s.state = "errored".to_string();
s.unresumable = true;
s
};
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert(
"__stale_daemon_detected__".to_string(),
chrono::Utc::now().to_rfc3339(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![confirmed_session()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 0, "a fresh sentinel must still block");
assert_eq!(outcome.kept.len(), 1);
assert_eq!(outcome.pending, 1, "held record must fold into pending");
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert(
"__stale_daemon_detected__".to_string(),
"2020-01-01T00:00:00Z".to_string(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![confirmed_session()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"the stub is still stale, so the retry must not count as pruned"
);
assert_eq!(outcome.kept.len(), 1);
let raw = std::fs::read_to_string(&marker_path).expect("read marker file");
let persisted: std::collections::HashMap<String, String> =
serde_json::from_str(&raw).expect("parse marker file");
let sentinel_ts = persisted
.get("__stale_daemon_detected__")
.expect("sentinel must be re-written after retry");
let parsed = chrono::DateTime::parse_from_rfc3339(sentinel_ts).expect("valid RFC 3339");
assert!(
chrono::Utc::now().signed_duration_since(parsed) < chrono::Duration::minutes(1),
"the re-tripped sentinel must be freshly timestamped, not the expired one"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_counts_failed_decommissions_so_the_banner_still_prints() {
use crate::commands::managed::filter_live_sessions;
use crate::commands::session_picker_prune::pending_banner;
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(
async |axum::extract::Path(_id): axum::extract::Path<String>| {
axum::http::StatusCode::INTERNAL_SERVER_ERROR
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let mut seen = std::collections::HashMap::new();
seen.insert("id-a".to_string(), "2020-01-01T00:00:00Z".to_string());
seen.insert("id-b".to_string(), "2020-01-01T00:00:00Z".to_string());
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let dead_session = |id: &str, label: &str| {
let mut s = stopped_session_at(id, &root.path().join(label));
s.state = "errored".to_string();
s.unresumable = true;
s
};
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![
dead_session("id-a", "gone-a"),
dead_session("id-b", "gone-b"),
],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"an HTTP 500 must never be counted as a successful prune"
);
assert_eq!(
outcome.kept.len(),
2,
"both records survive the failed sweep"
);
let mut hidden: Vec<&str> = outcome.dead_ids.iter().map(String::as_str).collect();
hidden.sort_unstable();
assert_eq!(
hidden,
vec!["id-a", "id-b"],
"both records stay classified dead, so the default view hides both"
);
assert_eq!(
pending_banner(outcome.pending, true).as_deref(),
Some("tm: 2 more dead records pending confirmation (hidden; `tm ls --all` to see them)"),
"a call whose every decommission failed must still name the withheld \
rows — before this fix `pending` was 0 and nothing printed at all"
);
assert_eq!(
outcome.pending,
outcome.dead_ids.len(),
"the reported count and the hidden set must be the same rows"
);
assert!(
filter_live_sessions(outcome.kept, &outcome.dead_ids).is_empty(),
"the default view drops exactly the rows the banner just accounted for"
);
handle.abort();
}
fn stopped_session_at(
id: &str,
workdir: &std::path::Path,
) -> trusty_mpm::client::ManagedSessionSummary {
let mut s = ls_test_session(id, "stopped", None, None, None, None);
s.id = id.to_string();
s.workspace_path = Some(workdir.to_string_lossy().to_string());
s.cwd = Some(workdir.to_string_lossy().to_string());
s.unresumable = false;
s
}
async fn spawn_recording_decommission_stub() -> (
String,
std::sync::Arc<std::sync::Mutex<Vec<String>>>,
tokio::task::JoinHandle<()>,
) {
let captured: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let sink = captured.clone();
let app = axum::Router::new().route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post(
move |axum::extract::Path(id): axum::extract::Path<String>,
axum::extract::RawQuery(q): axum::extract::RawQuery| {
let sink = sink.clone();
async move {
sink.lock()
.expect("capture lock")
.push(format!("{id}?{}", q.unwrap_or_default()));
axum::Json(serde_json::json!({ "workspace_removed": false }))
}
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind loopback port");
let addr = listener.local_addr().expect("resolve bound addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
(format!("http://{addr}"), captured, handle)
}
#[tokio::test]
async fn auto_prune_clears_stopped_record_whose_workspace_is_gone() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("workspace-that-never-existed");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let first = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("z1", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(first.pruned, 0, "a first sighting must never be pruned");
assert_eq!(first.pending, 1);
assert!(
captured.lock().expect("capture lock").is_empty(),
"a first sighting must issue no decommission call at all"
);
backdate_sightings(&marker_path, 11);
let second = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("z1", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
second.pruned, 1,
"a confirmed stopped record with a verifiably absent workspace must be cleared"
);
assert!(second.kept.is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_keeps_stopped_record_whose_workspace_still_exists() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let present = root.path().join("live-workspace");
std::fs::create_dir_all(&present).expect("create workspace");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
for round in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("keeper", &present)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"round {round}: a stopped record with a workspace ON DISK must never be cleared"
);
assert_eq!(outcome.kept.len(), 1, "round {round}: it must stay listed");
assert_eq!(
outcome.pending, 0,
"round {round}: it is not even a pending candidate"
);
}
assert!(
captured.lock().expect("capture lock").is_empty(),
"no decommission call may ever be issued for a live workspace"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_never_touches_a_running_record() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("nowhere");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut live = stopped_session_at("alive", &gone);
live.state = "active".to_string();
for _ in 0..2 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![live.clone()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 0, "a running record must never be pruned");
assert_eq!(outcome.kept.len(), 1);
}
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_never_touches_a_decommissioned_record() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("nowhere");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut tombstone = stopped_session_at("tombstone", &gone);
tombstone.state = "decommissioned".to_string();
let mut persisted_terminal = stopped_session_at("terminal", &gone);
persisted_terminal.persisted_state = Some("decommissioned".to_string());
for _ in 0..2 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![tombstone.clone(), persisted_terminal.clone()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 0);
assert_eq!(outcome.kept.len(), 2);
}
assert!(
captured.lock().expect("capture lock").is_empty(),
"a decommissioned record must never be decommissioned again"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_always_requests_record_only_never_full_teardown() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut flagged = stopped_session_at("flagged", &gone);
flagged.unresumable = true;
let probed = stopped_session_at("probed", &gone);
let sessions = vec![flagged.clone(), probed.clone()];
auto_prune_dead_records_at(&client, &url, sessions, &marker_path, Some(HashSet::new())).await;
backdate_sightings(&marker_path, 11);
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![flagged, probed],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(outcome.pruned, 2, "both dead records must be cleared");
let calls = captured.lock().expect("capture lock").clone();
assert_eq!(
calls.len(),
2,
"exactly two decommission calls, got {calls:?}"
);
for call in &calls {
assert!(
call.contains("record_only=true"),
"every decommission the sweep issues must be RECORD-ONLY — a call \
without it takes the full worktree-removal teardown: {call}"
);
}
handle.abort();
}
#[tokio::test]
async fn session_ls_prunes_dead_records_on_piped_invocation() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "piped").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let ls = async |ctx: &crate::commands::session_picker_prune::PruneContext| {
crate::commands::managed::session_ls_at(
&client,
&server.url,
false, None,
false,
crate::commands::session_picker::SessionSortArg::Recent,
None,
ctx,
)
.await
.expect("session_ls");
};
let ctx = hermetic_prune_ctx(&marker_path);
ls(&ctx).await;
assert!(
fetch_raw_live(&client, &server.url)
.await
.iter()
.any(|s| s.id == id.to_string()),
"a first sighting must leave the record in place"
);
backdate_sightings(&marker_path, 11);
let ctx = hermetic_prune_ctx(&marker_path);
ls(&ctx).await;
let live = default_view_pre_4994(fetch_raw_live(&client, &server.url).await);
assert!(
!live.iter().any(|s| s.id == id.to_string()),
"a non-TTY `tm ls` must clear a confirmed dead record from the registry"
);
}
#[tokio::test]
async fn session_ls_json_passthrough_prunes_dead_records() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let state = std::sync::Arc::new(
DaemonState::with_root_isolated_managed(root.path().to_path_buf()).await,
);
let mgr = state.session_manager().await;
let id = seed_dead_session(&mgr, root.path(), "json").await;
let server = LocalTestServer::spawn(state).await;
let client = reqwest::Client::new();
let ls_json = async |ctx: &crate::commands::session_picker_prune::PruneContext| {
crate::commands::managed::session_ls_at(
&client,
&server.url,
true, None,
false,
crate::commands::session_picker::SessionSortArg::Recent,
None,
ctx,
)
.await
.expect("session_ls --json");
};
ls_json(&hermetic_prune_ctx(&marker_path)).await;
backdate_sightings(&marker_path, 11);
ls_json(&hermetic_prune_ctx(&marker_path)).await;
let live = default_view_pre_4994(fetch_raw_live(&client, &server.url).await);
assert!(
!live.iter().any(|s| s.id == id.to_string()),
"`tm ls --json` must clear a confirmed dead record from the registry"
);
}
#[tokio::test]
async fn auto_prune_keeps_record_whose_parent_directory_is_unreachable() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let unreachable = root
.path()
.join("unmounted-volume")
.join("work")
.join("proj");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
for round in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("on-the-volume", &unreachable)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"round {round}: an unreachable parent is NOT proof the workspace is gone"
);
if marker_path.exists() {
backdate_sightings(&marker_path, 11);
}
}
assert!(
captured.lock().expect("capture lock").is_empty(),
"no decommission may be issued for a path whose parent is unreachable"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_clears_record_whose_worktree_root_survived_the_removal() {
for (marker_dir, container, worktree) in [
(".claude", "worktrees", "critic-4850"),
(".base", ".worktrees", "qa-4850"),
] {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let worktree_root = root.path().join(marker_dir).join(container);
std::fs::create_dir_all(&worktree_root).expect("create worktree root");
let removed = worktree_root
.join(worktree)
.join("crates")
.join("trusty-mpm");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let first = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("tm-deadrt40493-01", &removed)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(first.pruned, 0, "{container}: first sighting never prunes");
assert_eq!(
first.pending, 1,
"{container}: the record must be RECORDED as dead — a record that \
never reaches `pending` never gets a marker, which is why #4872's \
four records could not advance across repeated `tm ls` calls"
);
backdate_sightings(&marker_path, 11);
let second = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("tm-deadrt40493-01", &removed)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
second.pruned, 1,
"{container}: a removed worktree whose ROOT still exists is \
verifiably deleted, not unmounted, and must be cleared"
);
assert!(second.kept.is_empty(), "{container}: nothing left to keep");
assert_eq!(
captured.lock().expect("capture lock").len(),
1,
"{container}: exactly one decommission call"
);
handle.abort();
}
}
#[tokio::test]
async fn auto_prune_keeps_record_when_the_worktree_root_itself_is_gone() {
for (marker_dir, container, worktree) in [
(".claude", "worktrees", "critic-4850"),
(".base", ".worktrees", "qa-4850"),
] {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let removed = root
.path()
.join("unmounted-volume")
.join(marker_dir)
.join(container)
.join(worktree)
.join("crates")
.join("trusty-mpm");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
for round in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("on-the-volume", &removed)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"{container} round {round}: an absent worktree ROOT means the \
filesystem is unreachable, not that the worktree was removed"
);
if marker_path.exists() {
backdate_sightings(&marker_path, 11);
}
}
assert!(
captured.lock().expect("capture lock").is_empty(),
"{container}: no decommission may be issued for an unreachable volume"
);
handle.abort();
}
}
#[tokio::test]
async fn auto_prune_ignores_daemon_unresumable_when_parent_is_unreachable() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let unreachable = root.path().join("unmounted-volume").join("proj");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut flagged = stopped_session_at("daemon-says-dead", &unreachable);
flagged.unresumable = true;
for _ in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![flagged.clone()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"the daemon's unresumable flag must not override the client's own verification"
);
if marker_path.exists() {
backdate_sightings(&marker_path, 11);
}
}
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_does_not_confirm_two_calls_in_quick_succession() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
for round in 0..10 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("hasty", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"round {round}: repeated calls inside the sighting window must never confirm"
);
}
assert!(
captured.lock().expect("capture lock").is_empty(),
"a tight loop must not be able to drive any decommission at all"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_does_not_restamp_a_sighting_still_inside_the_window() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let seeded = (chrono::Utc::now() - chrono::Duration::minutes(6)).to_rfc3339();
let mut seen = std::collections::HashMap::new();
seen.insert("midwindow".to_string(), seeded.clone());
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("midwindow", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"6 minutes is inside the 10-minute window"
);
let raw = std::fs::read_to_string(&marker_path).expect("read marker");
let persisted: std::collections::HashMap<String, String> =
serde_json::from_str(&raw).expect("parse marker");
assert_eq!(
persisted.get("midwindow"),
Some(&seeded),
"the ORIGINAL first-sighting stamp must survive an intervening listing — \
restamping makes the window measure listing cadence, not age, and any \
`tm ls` more often than every 10 minutes then leaves auto-prune inert"
);
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_confirms_despite_frequent_intervening_listings() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, _captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let listing = async |marker: &std::path::Path| {
auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("patient", &gone)],
marker,
Some(HashSet::new()),
)
.await
.pruned
};
assert_eq!(listing(&marker_path).await, 0);
age_sightings_by(&marker_path, 6);
assert_eq!(
listing(&marker_path).await,
0,
"a listing 6 minutes in must not confirm"
);
age_sightings_by(&marker_path, 5);
assert_eq!(
listing(&marker_path).await,
1,
"at 11 minutes of real age the record must confirm — a listing at T+6 \
must not have reset the clock"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_never_touches_an_errored_record_with_a_live_detached_pane() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut errored = stopped_session_at("tm-live-agent-01", &gone);
errored.state = "errored".to_string();
errored.unresumable = true;
errored.attached = false;
let live: HashSet<String> = ["tm-live-agent-01".to_string()].into_iter().collect();
for round in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![errored.clone()],
&marker_path,
Some(live.clone()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"round {round}: a live tmux session must never be tombstoned into a \
terminal state, detached or not"
);
if marker_path.exists() {
backdate_sightings(&marker_path, 11);
}
}
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_prunes_nothing_when_tmux_cannot_be_enumerated() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut seen = std::collections::HashMap::new();
seen.insert(
"confirmed-but-blind".to_string(),
(chrono::Utc::now() - chrono::Duration::minutes(30)).to_rfc3339(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("confirmed-but-blind", &gone)],
&marker_path,
None, )
.await;
assert_eq!(
outcome.pruned, 0,
"no liveness signal means no clearing, even for a long-confirmed record"
);
assert_eq!(outcome.kept.len(), 1);
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn auto_prune_confirms_once_the_sighting_window_has_elapsed() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, _captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("patient", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
backdate_sightings(&marker_path, 11);
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("patient", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 1,
"an elapsed sighting window must confirm — the bound is a delay, not a lockout"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_treats_an_unparseable_sighting_as_a_fresh_one() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut seen = std::collections::HashMap::new();
seen.insert("corrupt".to_string(), "not-a-timestamp".to_string());
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![stopped_session_at("corrupt", &gone)],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"an unparseable sighting must never count as confirmed"
);
assert!(captured.lock().expect("capture lock").is_empty());
let raw = std::fs::read_to_string(&marker_path).expect("read marker");
let persisted: std::collections::HashMap<String, String> =
serde_json::from_str(&raw).expect("parse marker");
let stamp = persisted.get("corrupt").expect("entry must survive");
assert!(
chrono::DateTime::parse_from_rfc3339(stamp).is_ok(),
"the corrupt entry must be restamped with a valid timestamp, got {stamp:?}"
);
handle.abort();
}
#[tokio::test]
async fn auto_prune_never_touches_an_attached_record() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let (url, captured, handle) = spawn_recording_decommission_stub().await;
let client = reqwest::Client::new();
let mut attached = stopped_session_at("attached-errored", &gone);
attached.state = "errored".to_string();
attached.unresumable = true;
attached.attached = true;
for _ in 0..3 {
let outcome = auto_prune_dead_records_at(
&client,
&url,
vec![attached.clone()],
&marker_path,
Some(HashSet::new()),
)
.await;
assert_eq!(
outcome.pruned, 0,
"an attached session must never be cleared"
);
if marker_path.exists() {
backdate_sightings(&marker_path, 11);
}
}
assert!(captured.lock().expect("capture lock").is_empty());
handle.abort();
}
#[tokio::test]
async fn session_ls_json_never_refetches_after_pruning() {
let root = tempfile::TempDir::new().expect("tempdir");
let marker_path = root.path().join("seen.json");
let gone = root.path().join("gone");
let gets = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let deletes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let body = serde_json::json!({
"sessions": [{
"id": "j1",
"name": "j1",
"state": "stopped",
"persisted_state": "stopped",
"workspace_path": gone.to_string_lossy(),
"cwd": gone.to_string_lossy(),
"created_at": "2026-08-01T00:00:00Z",
"unresumable": false,
"slot": 1,
"deleted": false,
}]
});
let get_sink = gets.clone();
let app = axum::Router::new()
.route(
"/api/v1/sessions/managed",
axum::routing::get(move || {
let get_sink = get_sink.clone();
let body = body.clone();
async move {
get_sink.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
axum::Json(body)
}
}),
)
.route(
"/api/v1/sessions/managed/{id}/decommission",
axum::routing::post({
let delete_sink = deletes.clone();
move |axum::extract::Path(_id): axum::extract::Path<String>| {
let delete_sink = delete_sink.clone();
async move {
delete_sink.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
axum::Json(serde_json::json!({ "workspace_removed": false }))
}
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let addr = listener.local_addr().expect("addr");
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, app).await;
});
let url = format!("http://{addr}");
let client = reqwest::Client::new();
let mut seen = std::collections::HashMap::new();
seen.insert(
"j1".to_string(),
(chrono::Utc::now() - chrono::Duration::minutes(11)).to_rfc3339(),
);
std::fs::write(&marker_path, serde_json::to_string(&seen).unwrap()).unwrap();
crate::commands::managed::session_ls_at(
&client,
&url,
true,
None,
false,
crate::commands::session_picker::SessionSortArg::Recent,
None,
&hermetic_prune_ctx(&marker_path),
)
.await
.expect("session_ls --json");
assert_eq!(
deletes.load(std::sync::atomic::Ordering::SeqCst),
1,
"precondition: this invocation must genuinely prune a record — otherwise \
'exactly one GET' is vacuous"
);
assert_eq!(
gets.load(std::sync::atomic::Ordering::SeqCst),
1,
"`--json` must issue exactly ONE GET even when the prune removed a record"
);
handle.abort();
}
#[test]
fn cli_parses_f_pattern() {
let cli = Cli::try_parse_from(["trusty-mpm", "f", "api"]).unwrap();
match cli.command.unwrap() {
Command::F(a) => assert_eq!(a.pattern, vec!["api".to_string()]),
other => panic!("expected top-level f, got {other:?}"),
}
}
#[test]
fn cli_parses_f_multi_word_pattern() {
let cli = Cli::try_parse_from(["trusty-mpm", "f", "alpha", "api"]).unwrap();
match cli.command.unwrap() {
Command::F(a) => {
assert_eq!(a.pattern, vec!["alpha".to_string(), "api".to_string()])
}
other => panic!("expected top-level f, got {other:?}"),
}
}
#[test]
fn cli_parses_f_bare_is_allowed() {
let cli = Cli::try_parse_from(["trusty-mpm", "f"]).unwrap();
match cli.command.unwrap() {
Command::F(a) => assert!(a.pattern.is_empty()),
other => panic!("expected top-level f, got {other:?}"),
}
}
#[test]
fn cli_parses_f_json() {
let cli = Cli::try_parse_from(["trusty-mpm", "f", "--json", "api"]).unwrap();
match cli.command.unwrap() {
Command::F(a) => {
assert_eq!(a.pattern, vec!["api".to_string()]);
assert!(a.json);
}
other => panic!("expected top-level f --json, got {other:?}"),
}
}
#[test]
fn cli_f_source_id_and_current_conflict() {
let err = Cli::try_parse_from([
"trusty-mpm",
"f",
"--source-id",
"owner/repo",
"--current",
"api",
]);
assert!(err.is_err(), "--source-id and --current must conflict");
}
#[test]
fn cli_f_does_not_shadow_another_subcommand() {
let cli = Cli::try_parse_from(["trusty-mpm", "f", "x"]).unwrap();
assert!(matches!(cli.command, Some(Command::F(_))));
}
#[test]
fn filter_sessions_by_name_ignores_non_name_columns() {
let sessions = vec![
ls_test_session(
"tm-alpha-01",
"active",
None,
None,
Some("acme/api-server"),
Some("fix the api"),
),
ls_test_session("tm-api-02", "active", None, None, None, None),
];
let by_name = filter_sessions_by_term(sessions.clone(), Some(&SessionFilter::name("api")));
assert_eq!(
by_name.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
vec!["tm-api-02"],
"NAME scope must ignore a matching task and project slug"
);
let by_visible = filter_sessions_by_term(sessions, Some(&SessionFilter::visible("api")));
assert_eq!(
by_visible.len(),
2,
"the ls scope still matches task/source_id"
);
}