mod common;
use rusqlite::Connection;
fn state_db(dir: &std::path::Path) -> Connection {
Connection::open(dir.join(".git/carryctx/state.sqlite")).unwrap()
}
fn cleanup_state(dir: &std::path::Path) -> (String, Option<String>) {
state_db(dir)
.query_row(
"SELECT state, blocked_reason FROM worktree_cleanup_requests ORDER BY requested_at DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap()
}
#[test]
fn test_full_session_lifecycle() {
let (dir, bin) = common::setup_test_project("session_test");
common::init_and_agent(&dir, &bin);
let start = common::run_cmd(&dir, &bin, &["session", "start"]);
assert!(start.status.success(), "session start should succeed");
let current = common::run_cmd(&dir, &bin, &["session", "current", "--json"]);
assert!(current.status.success(), "session current should succeed");
let stdout = String::from_utf8_lossy(¤t.stdout);
assert!(
stdout.contains("active"),
"current session should be active"
);
let pause = common::run_cmd(&dir, &bin, &["session", "pause", "--json"]);
assert!(pause.status.success(), "session pause should succeed");
let stdout = String::from_utf8_lossy(&pause.stdout);
assert!(
stdout.contains("paused"),
"paused session should show paused state"
);
let resume = common::run_cmd(&dir, &bin, &["session", "resume", "--json"]);
assert!(resume.status.success(), "session resume should succeed");
let end = common::run_cmd(&dir, &bin, &["session", "end", "--json"]);
assert!(end.status.success(), "session end should succeed");
}
#[test]
fn session_end_reconciles_cleanup_blocked_by_that_session() {
let (dir, bin) = common::setup_test_project("session_end_cleanup");
common::init_and_agent(&dir, &bin);
let task = common::run_cmd(
&dir,
&bin,
&["--json", "task", "create", "--title", "session cleanup"],
);
assert!(task.status.success());
let task_ref =
serde_json::from_slice::<serde_json::Value>(&task.stdout).unwrap()["data"]["display_id"]
.as_str()
.unwrap()
.to_string();
assert!(
common::run_cmd(&dir, &bin, &["task", "start", &task_ref])
.status
.success()
);
let worktree = dir.parent().unwrap().join("session-end-cleanup-worktree");
let _ = std::fs::remove_dir_all(&worktree);
assert!(
common::run_cmd(
&dir,
&bin,
&[
"worktree",
"create",
&task_ref,
"--path",
worktree.to_str().unwrap()
],
)
.status
.success()
);
let worktree_id: String = state_db(&dir)
.query_row("SELECT id FROM worktrees LIMIT 1", [], |row| row.get(0))
.unwrap();
let start = common::run_cmd(
&dir,
&bin,
&[
"session",
"start",
"--task",
&task_ref,
"--worktree",
&worktree_id,
],
);
assert!(
start.status.success(),
"{}",
String::from_utf8_lossy(&start.stderr)
);
assert!(
common::run_cmd(&dir, &bin, &["task", "complete", &task_ref])
.status
.success()
);
assert_eq!(cleanup_state(&dir).0, "blocked");
assert!(worktree.exists());
assert!(
common::run_cmd(
&dir,
&bin,
&[
"--non-interactive",
"checkpoint",
"--task",
&task_ref,
"--no-git"
]
)
.status
.success()
);
let end = common::run_cmd(
&dir,
&bin,
&["--json", "--non-interactive", "session", "end"],
);
assert!(
end.status.success(),
"{}",
String::from_utf8_lossy(&end.stderr)
);
let envelope: serde_json::Value = serde_json::from_slice(&end.stdout).unwrap();
assert_eq!(envelope["success"], true);
assert_eq!(envelope["data"]["state"], "ended");
assert_eq!(cleanup_state(&dir), ("completed".into(), None));
assert!(!worktree.exists());
let removed: i64 = state_db(&dir)
.query_row(
"SELECT COUNT(*) FROM events WHERE type='worktree.removed'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(removed, 1);
}
#[test]
fn session_end_requires_checkpoint_by_default_in_non_interactive_mode() {
let (dir, bin) = common::setup_test_project("session_end_checkpoint_required");
common::init_and_agent(&dir, &bin);
let task = common::run_cmd(
&dir,
&bin,
&["--json", "task", "create", "--title", "checkpoint required"],
);
let task_ref =
serde_json::from_slice::<serde_json::Value>(&task.stdout).unwrap()["data"]["display_id"]
.as_str()
.unwrap()
.to_string();
assert!(
common::run_cmd(&dir, &bin, &["task", "start", &task_ref])
.status
.success()
);
assert!(
common::run_cmd(&dir, &bin, &["session", "start", "--task", &task_ref])
.status
.success()
);
let end = common::run_cmd(
&dir,
&bin,
&["--json", "--non-interactive", "session", "end"],
);
assert!(!end.status.success());
let value: serde_json::Value = serde_json::from_slice(&end.stderr).unwrap();
assert_eq!(value["success"], false);
assert!(
value["error"]["message"]
.as_str()
.unwrap()
.contains("checkpoint is required")
);
let state: String = state_db(&dir)
.query_row("SELECT state FROM sessions LIMIT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(state, "active");
}
#[test]
fn session_end_refuses_checkpoint_lookup_errors_without_mutating_session() {
let (dir, bin) = common::setup_test_project("session_end_checkpoint_lookup_error");
common::init_and_agent(&dir, &bin);
let task = common::run_cmd(
&dir,
&bin,
&["--json", "task", "create", "--title", "lookup error"],
);
let task_ref =
serde_json::from_slice::<serde_json::Value>(&task.stdout).unwrap()["data"]["display_id"]
.as_str()
.unwrap()
.to_string();
assert!(
common::run_cmd(&dir, &bin, &["task", "start", &task_ref])
.status
.success()
);
assert!(
common::run_cmd(&dir, &bin, &["session", "start", "--task", &task_ref])
.status
.success()
);
state_db(&dir)
.execute("ALTER TABLE checkpoints RENAME TO checkpoints_broken", [])
.unwrap();
let end = common::run_cmd(
&dir,
&bin,
&["--json", "--non-interactive", "session", "end"],
);
assert!(!end.status.success());
let value: serde_json::Value = serde_json::from_slice(&end.stderr).unwrap();
assert_eq!(value["success"], false);
assert_eq!(value["error"]["code"], "DATABASE_ERROR");
assert!(
value["error"]["message"]
.as_str()
.unwrap()
.contains("Checkpoint verification failed")
);
let state: String = state_db(&dir)
.query_row("SELECT state FROM sessions LIMIT 1", [], |row| row.get(0))
.unwrap();
assert_eq!(state, "active");
}
#[test]
fn session_end_skips_checkpoint_requirement_when_disabled() {
let (dir, bin) = common::setup_test_project("session_end_checkpoint_disabled");
common::init_and_agent(&dir, &bin);
std::fs::write(
dir.join(".carryctx/config.toml"),
"[checkpoint]\nrequire_before_session_end = false\n",
)
.unwrap();
let task = common::run_cmd(
&dir,
&bin,
&["--json", "task", "create", "--title", "checkpoint optional"],
);
let task_ref =
serde_json::from_slice::<serde_json::Value>(&task.stdout).unwrap()["data"]["display_id"]
.as_str()
.unwrap()
.to_string();
assert!(
common::run_cmd(&dir, &bin, &["task", "start", &task_ref])
.status
.success()
);
assert!(
common::run_cmd(&dir, &bin, &["session", "start", "--task", &task_ref])
.status
.success()
);
let end = common::run_cmd(&dir, &bin, &["--json", "session", "end"]);
assert!(end.status.success());
let value: serde_json::Value = serde_json::from_slice(&end.stdout).unwrap();
assert_eq!(value["success"], true);
assert!(
value.get("warnings").is_none()
|| value["warnings"]
.as_array()
.unwrap()
.iter()
.all(|warning| !warning.as_str().unwrap().contains("No checkpoint exists"))
);
}
#[test]
fn session_end_rolls_back_state_when_audit_event_fails() {
let (dir, bin) = common::setup_test_project("session_end_atomicity");
common::init_and_agent(&dir, &bin);
assert!(
common::run_cmd(&dir, &bin, &["session", "start"])
.status
.success()
);
let db = state_db(&dir);
db.execute(
"CREATE TRIGGER reject_session_end BEFORE INSERT ON events WHEN NEW.type = 'session.ended' BEGIN SELECT RAISE(ABORT, 'injected session end audit failure'); END",
[],
)
.unwrap();
drop(db);
let end = common::run_cmd(
&dir,
&bin,
&["--json", "--non-interactive", "session", "end"],
);
assert!(!end.status.success());
let db = state_db(&dir);
let state: String = db
.query_row("SELECT state FROM sessions LIMIT 1", [], |row| row.get(0))
.unwrap();
let ended_events: i64 = db
.query_row(
"SELECT COUNT(*) FROM events WHERE type = 'session.ended'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(state, "active");
assert_eq!(ended_events, 0);
}
#[test]
fn session_end_keeps_dirty_cleanup_blocked_and_succeeds() {
let (dir, bin) = common::setup_test_project("session_end_dirty_cleanup");
common::init_and_agent(&dir, &bin);
let task = common::run_cmd(
&dir,
&bin,
&[
"--json",
"task",
"create",
"--title",
"dirty session cleanup",
],
);
let task_ref =
serde_json::from_slice::<serde_json::Value>(&task.stdout).unwrap()["data"]["display_id"]
.as_str()
.unwrap()
.to_string();
assert!(
common::run_cmd(&dir, &bin, &["task", "start", &task_ref])
.status
.success()
);
let worktree = dir.parent().unwrap().join("session-end-dirty-worktree");
let _ = std::fs::remove_dir_all(&worktree);
assert!(
common::run_cmd(
&dir,
&bin,
&[
"worktree",
"create",
&task_ref,
"--path",
worktree.to_str().unwrap()
]
)
.status
.success()
);
let worktree_id: String = state_db(&dir)
.query_row("SELECT id FROM worktrees LIMIT 1", [], |row| row.get(0))
.unwrap();
assert!(
common::run_cmd(
&dir,
&bin,
&[
"session",
"start",
"--task",
&task_ref,
"--worktree",
&worktree_id
]
)
.status
.success()
);
std::fs::write(worktree.join("dirty.txt"), "dirty\n").unwrap();
assert!(
common::run_cmd(
&dir,
&bin,
&[
"--non-interactive",
"checkpoint",
"--task",
&task_ref,
"--no-git"
]
)
.status
.success()
);
assert!(
common::run_cmd(&dir, &bin, &["task", "complete", &task_ref])
.status
.success()
);
let end = common::run_cmd(
&dir,
&bin,
&["--json", "--non-interactive", "session", "end"],
);
assert!(end.status.success());
let envelope: serde_json::Value = serde_json::from_slice(&end.stdout).unwrap();
assert_eq!(envelope["success"], true);
assert_eq!(cleanup_state(&dir).0, "blocked");
assert!(worktree.exists());
}
#[test]
fn session_end_reconciles_taskless_manual_cleanup_request() {
let (dir, bin) = common::setup_test_project("session_end_manual_cleanup");
common::init_and_agent(&dir, &bin);
let db = state_db(&dir);
let project_id: String = db
.query_row("SELECT id FROM projects LIMIT 1", [], |row| row.get(0))
.unwrap();
db.execute(
"INSERT INTO worktree_cleanup_requests (id, project_id, worktree_id, worktree_path, branch, task_id, reason, state, attempt_count, requested_at) VALUES ('manual-session-end', ?1, NULL, ?2, NULL, NULL, 'manual', 'pending', 0, 'now')",
rusqlite::params![project_id, dir.join("missing-manual").to_string_lossy()],
)
.unwrap();
drop(db);
assert!(
common::run_cmd(&dir, &bin, &["session", "start"])
.status
.success()
);
let end = common::run_cmd(&dir, &bin, &["--json", "session", "end"]);
assert!(end.status.success());
assert_eq!(cleanup_state(&dir), ("pending".into(), None));
}
#[test]
fn test_session_list() {
let (dir, bin) = common::setup_test_project("session_list_test");
common::init_and_agent(&dir, &bin);
common::run_cmd(&dir, &bin, &["session", "start"]);
let list = common::run_cmd(&dir, &bin, &["session", "list", "--json"]);
assert!(list.status.success(), "session list should succeed");
let stdout = String::from_utf8_lossy(&list.stdout);
assert!(
stdout.contains("active") || stdout.contains("ended"),
"list should contain session state"
);
}
#[test]
fn test_session_superseded_event_points_to_successor_session() {
let (dir, bin) = common::setup_test_project("session_superseded_event");
common::init_and_agent(&dir, &bin);
let first = common::run_cmd(&dir, &bin, &["session", "start", "--json"]);
assert!(first.status.success(), "first session start should succeed");
let first_json: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap();
let first_id = first_json["data"]["id"].as_str().unwrap();
let second = common::run_cmd(&dir, &bin, &["session", "start", "--json"]);
assert!(
second.status.success(),
"second session start should succeed"
);
let second_json: serde_json::Value = serde_json::from_slice(&second.stdout).unwrap();
let second_id = second_json["data"]["id"].as_str().unwrap();
assert_ne!(first_id, second_id);
let events = common::run_cmd(
&dir,
&bin,
&[
"event",
"list",
"--session",
first_id,
"--event-type",
"session.ended",
"--json",
],
);
assert!(events.status.success(), "event list should succeed");
let events_json: serde_json::Value = serde_json::from_slice(&events.stdout).unwrap();
let event_list = events_json["data"]["events"].as_array().unwrap();
assert!(
!event_list.is_empty(),
"supersession event should be recorded"
);
let superseded_by = event_list[0]["payload"]["superseded_by"].as_str().unwrap();
assert_eq!(superseded_by, second_id);
let successor = common::run_cmd(&dir, &bin, &["session", "show", superseded_by, "--json"]);
assert!(successor.status.success(), "successor session should exist");
}
#[test]
fn test_session_transitions_require_owner_agent() {
let (dir, bin) = common::setup_test_project("session_ownership");
common::run_cmd(&dir, &bin, &["init", "--force"]);
for name in ["alice", "bob"] {
let out = common::run_cmd(
&dir,
&bin,
&["agent", "register", "--name", name, "--provider", "test"],
);
assert!(out.status.success(), "register {name} failed");
}
let start = common::run_cmd_as(&dir, &bin, "alice", &["session", "start", "--json"]);
assert!(
start.status.success(),
"alice start: {}",
String::from_utf8_lossy(&start.stderr)
);
let value: serde_json::Value = serde_json::from_slice(&start.stdout).unwrap();
let sid = value["data"]["id"]
.as_str()
.expect("session id")
.to_string();
for args in [
vec!["session", "pause", "--json"],
vec!["session", "end", "--json"],
] {
let out = common::run_cmd_as(&dir, &bin, "bob", &args);
assert!(
!out.status.success(),
"bob must not be able to {:?} alice's session",
args
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("PERMISSION_SCOPE"),
"foreign-agent {} must report PERMISSION_SCOPE: {}",
args[0],
String::from_utf8_lossy(&out.stderr)
);
}
let show = common::run_cmd(&dir, &bin, &["session", "show", &sid, "--json"]);
assert!(show.status.success());
let value: serde_json::Value = serde_json::from_slice(&show.stdout).unwrap();
assert_eq!(
value["data"]["state"], "active",
"session state after bob's attempts: {value}"
);
let pause = common::run_cmd_as(&dir, &bin, "alice", &["session", "pause", "--json"]);
assert!(
pause.status.success(),
"owner pause: {}",
String::from_utf8_lossy(&pause.stderr)
);
let resume = common::run_cmd_as(&dir, &bin, "alice", &["session", "resume", "--json"]);
assert!(
resume.status.success(),
"owner resume: {}",
String::from_utf8_lossy(&resume.stderr)
);
let out = common::run_cmd_as(&dir, &bin, "bob", &["session", "resume", "--json"]);
assert!(!out.status.success(), "bob must not resume alice's session");
let end = common::run_cmd_as(&dir, &bin, "alice", &["session", "end", "--json"]);
assert!(
end.status.success(),
"owner end: {}",
String::from_utf8_lossy(&end.stderr)
);
}
#[test]
fn test_session_start_reuse_returns_active_session() {
let (dir, bin) = common::setup_test_project("session_reuse");
common::init_and_agent(&dir, &bin);
let first = common::run_cmd(&dir, &bin, &["session", "start", "--json"]);
assert!(first.status.success(), "first start should succeed");
let first_id =
serde_json::from_slice::<serde_json::Value>(&first.stdout).unwrap()["data"]["id"]
.as_str()
.unwrap()
.to_string();
let reused = common::run_cmd(&dir, &bin, &["session", "start", "--reuse", "--json"]);
assert!(reused.status.success(), "reuse start should succeed");
let reused_json: serde_json::Value = serde_json::from_slice(&reused.stdout).unwrap();
assert_eq!(
reused_json["data"]["id"].as_str().unwrap(),
first_id,
"--reuse must return the existing active session"
);
let list = common::run_cmd(&dir, &bin, &["session", "list", "--json"]);
let list_json: serde_json::Value = serde_json::from_slice(&list.stdout).unwrap();
let sessions = list_json["data"].as_array().expect("session array");
assert_eq!(sessions.len(), 1, "reuse must not create a new session");
let second = common::run_cmd(&dir, &bin, &["session", "start", "--json"]);
assert!(second.status.success(), "plain restart should succeed");
let second_id =
serde_json::from_slice::<serde_json::Value>(&second.stdout).unwrap()["data"]["id"]
.as_str()
.unwrap()
.to_string();
assert_ne!(second_id, first_id, "plain start must create a new session");
common::run_cmd(&dir, &bin, &["session", "end", "--json"]);
let fallback = common::run_cmd(&dir, &bin, &["session", "start", "--reuse", "--json"]);
assert!(
fallback.status.success(),
"--reuse without an active session must still start one"
);
}
#[test]
fn test_session_abandon_persists_reason_and_distinct_state() {
let (dir, bin) = common::setup_test_project("session_abandon_reason");
common::init_and_agent(&dir, &bin);
let start = common::run_cmd(&dir, &bin, &["session", "start"]);
assert!(start.status.success(), "session start should succeed");
let abandon = common::run_cmd(
&dir,
&bin,
&[
"--json",
"session",
"abandon",
"--reason",
"fatal build failure",
],
);
assert!(
abandon.status.success(),
"abandon should succeed: {} {}",
String::from_utf8_lossy(&abandon.stdout),
String::from_utf8_lossy(&abandon.stderr)
);
let stdout = String::from_utf8_lossy(&abandon.stdout);
assert!(
stdout.contains("abandoned"),
"the record must show the abandoned state: {stdout}"
);
let events = std::process::Command::new(&bin)
.args(["event", "list", "--limit", "100", "--json"])
.env_remove("CARRYCTX_AGENT")
.current_dir(&dir)
.output()
.expect("event list should execute");
assert!(events.status.success());
let value: serde_json::Value = serde_json::from_slice(&events.stdout).unwrap();
let abandoned: Vec<_> = value["data"]["events"]
.as_array()
.expect("events array")
.iter()
.filter(|e| e["event_type"] == "session.abandoned")
.collect();
assert_eq!(
abandoned.len(),
1,
"exactly one session.abandoned event: {value}"
);
assert_eq!(
abandoned[0]["payload"]["reason"], "fatal build failure",
"the reason must survive verbatim in the event payload: {value}"
);
let clean_end: Vec<_> = value["data"]["events"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["event_type"] == "session.ended")
.collect();
assert!(
clean_end.is_empty(),
"abandon must not record session.ended: {value}"
);
}
#[test]
fn test_session_start_honors_configured_default_agent_name() {
let (dir, bin) = common::setup_test_project("session_config_default");
common::run_cmd(&dir, &bin, &["init", "--force"]);
common::run_cmd(
&dir,
&bin,
&[
"agent",
"register",
"--name",
"claude-core",
"--provider",
"test",
],
);
std::fs::write(
dir.join(".carryctx/config.toml"),
"[agent]\ndefault_name = \"claude-core\"\n",
)
.unwrap();
let start = std::process::Command::new(&bin)
.args(["session", "start", "--json"])
.env_remove("CARRYCTX_AGENT")
.current_dir(&dir)
.output()
.expect("session start should execute");
assert!(
start.status.success(),
"session start must honor [agent] default_name: {} {}",
String::from_utf8_lossy(&start.stdout),
String::from_utf8_lossy(&start.stderr)
);
let agents = std::process::Command::new(&bin)
.args(["agent", "list", "--json"])
.env_remove("CARRYCTX_AGENT")
.current_dir(&dir)
.output()
.expect("agent list should execute");
let agents_value: serde_json::Value = serde_json::from_slice(&agents.stdout).unwrap();
let expected_id = agents_value["data"]
.as_array()
.and_then(|list| {
list.iter()
.find(|a| a["name"] == "claude-core")
.and_then(|a| a["id"].as_str())
})
.expect("claude-core must be listed");
let start_value: serde_json::Value = serde_json::from_slice(&start.stdout).unwrap();
assert_eq!(
start_value["data"]["agent_id"], expected_id,
"the session must bind to the configured default agent: {start_value}"
);
}