Recovery could not tell a daemon restart from a host reboot: both looked like "some sessions were not in tmux list-sessions", so the tmux probe ran unconditionally and the logs said nothing useful when it was actually a reboot. Record /proc/sys/kernel/random/boot_id in the state directory on every recover(). When it differs from the stored value, every session from the previous boot is provably dead, so the tmux probe is skipped and the second recovery pass registers them dormant directly, logging "host rebooted, sessions dormant" with the count. A missing stored value or an unreadable boot id (non-Linux, restricted /proc) falls back to today's probe-based recovery. The boot id source is injected via a BootIdSource trait so tests can simulate a changed or unreadable boot id without rebooting the machine. Refs #164
1550 lines
53 KiB
Rust
1550 lines
53 KiB
Rust
//! End-to-end tests for the SessionManager: create, kill, recover, broadcast.
|
|
|
|
mod common;
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
|
use maestro_daemon::SessionError;
|
|
use maestro_protocol::CreateSessionRequest;
|
|
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
|
use tempfile::TempDir;
|
|
|
|
#[tokio::test]
|
|
async fn create_then_kill_round_trip() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let session = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
assert!(fx.manager.has(session.id).await);
|
|
let snap = fx.manager.snapshot().await;
|
|
assert_eq!(snap.len(), 1);
|
|
assert_eq!(snap[0].id, session.id);
|
|
|
|
// sidecar should be on disk
|
|
assert!(fx
|
|
.sidecar_dir()
|
|
.join(format!("{}.json", session.id))
|
|
.exists());
|
|
// FIFO should also exist
|
|
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
|
|
|
|
fx.manager.kill(session.id, false).await.expect("kill");
|
|
assert!(!fx.manager.has(session.id).await);
|
|
assert!(!fx
|
|
.sidecar_dir()
|
|
.join(format!("{}.json", session.id))
|
|
.exists());
|
|
}
|
|
|
|
// #24: a successful spawn appends a record to the durable history, which
|
|
// `recent_dirs()` then surfaces — proving the create→append→read path end to
|
|
// end against a real tmux + on-disk JSONL log.
|
|
#[tokio::test]
|
|
async fn create_appends_to_spawn_history() {
|
|
let fx = DaemonFixture::new_with_history(30);
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let session = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::from([("name".into(), serde_json::json!("neon-drifter"))]),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// The JSONL log exists and the recents query surfaces the chosen dir.
|
|
assert!(fx.spawn_history_path().exists());
|
|
let recents = fx.manager.recent_dirs();
|
|
assert_eq!(recents.len(), 1);
|
|
assert_eq!(recents[0].cwd, cwd.path());
|
|
assert_eq!(recents[0].last_command, "sleep 30");
|
|
|
|
fx.manager.kill(session.id, false).await.expect("kill");
|
|
// History is durable — it survives the kill that deletes the sidecar.
|
|
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
|
}
|
|
|
|
// With history disabled (`0` days) or unwired, a spawn logs nothing and the
|
|
// recents query is empty — the spawn itself still succeeds.
|
|
#[tokio::test]
|
|
async fn create_without_history_logs_nothing() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
fx.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
assert!(fx.manager.recent_dirs().is_empty());
|
|
assert!(!fx.spawn_history_path().exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recover_adopts_existing_tmux_sessions() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
// create a session, then "throw away" the SessionManager (the tmux session
|
|
// and sidecar persist) and build a fresh manager that recovers them.
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// build a parallel manager pointing at the same dirs + same tmux server
|
|
let sidecar = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir());
|
|
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
|
let revived = maestro_daemon::SessionManager::new(
|
|
Arc::new(fx.tmux.tmux.clone()),
|
|
sidecar,
|
|
worktree_mgr,
|
|
fx.fifo_dir(),
|
|
);
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 1);
|
|
assert!(revived.has(s.id).await);
|
|
let snap = revived.snapshot().await;
|
|
assert_eq!(snap[0].id, s.id);
|
|
assert_eq!(snap[0].command, "sleep 30");
|
|
}
|
|
|
|
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
|
|
/// adopt while every sidecar is still on disk. Those sessions come back as
|
|
/// `Dormant` — a record of what was running — rather than vanishing from the
|
|
/// API and leaving the operator with nothing.
|
|
#[tokio::test]
|
|
async fn recover_registers_orphaned_sidecars_as_dormant() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
let mut metadata = HashMap::new();
|
|
metadata.insert(
|
|
maestro_protocol::NAME_KEY.to_string(),
|
|
serde_json::Value::String("after-reboot".into()),
|
|
);
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata,
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// The reboot: the tmux server is gone, the sidecar is untouched.
|
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
|
|
let revived = revive(&fx);
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 0, "no live tmux session left to adopt");
|
|
|
|
let snap = revived.snapshot().await;
|
|
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
|
|
assert_eq!(snap[0].id, s.id);
|
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
|
assert_eq!(snap[0].command, "sleep 30");
|
|
assert_eq!(snap[0].cwd, s.cwd);
|
|
assert_eq!(snap[0].created_at, s.created_at);
|
|
assert_eq!(
|
|
snap[0]
|
|
.metadata
|
|
.get(maestro_protocol::NAME_KEY)
|
|
.and_then(|v| v.as_str()),
|
|
Some("after-reboot"),
|
|
);
|
|
}
|
|
|
|
/// #162: a session tmux still holds is adopted `Running` by the first pass and
|
|
/// must not be re-registered as dormant by the second — the sidecar is on disk
|
|
/// for both.
|
|
#[tokio::test]
|
|
async fn recover_does_not_mark_adopted_sessions_dormant() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let live = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create live");
|
|
let orphan = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 31".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create orphan");
|
|
// Only the second session loses its pane.
|
|
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
|
|
|
|
let revived = revive(&fx);
|
|
assert_eq!(revived.recover().await.expect("recover"), 1);
|
|
|
|
let mut snap = revived.snapshot().await;
|
|
snap.sort_by_key(|s| s.command.clone());
|
|
assert_eq!(snap.len(), 2);
|
|
assert_eq!(snap[0].id, live.id);
|
|
assert_eq!(snap[0].status, SessionStatus::Running);
|
|
assert_eq!(snap[1].id, orphan.id);
|
|
assert_eq!(snap[1].status, SessionStatus::Dormant);
|
|
}
|
|
|
|
/// #163: a sidecar whose `cwd` no longer exists is pruned instead of
|
|
/// registered dormant — the worktree it pointed at is gone, so there is
|
|
/// nothing left to resume.
|
|
#[tokio::test]
|
|
async fn recover_deletes_sidecar_when_cwd_gone() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
let cwd_path = cwd.path().to_path_buf();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd_path.clone(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
drop(cwd);
|
|
std::fs::remove_dir_all(&cwd_path).ok();
|
|
assert!(!cwd_path.exists());
|
|
|
|
let revived = revive(&fx);
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 0);
|
|
|
|
assert!(!revived.has(s.id).await);
|
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
}
|
|
|
|
/// #163: a sidecar whose `cwd` still exists is left alone regardless of age
|
|
/// — it is registered dormant like any other orphan.
|
|
#[tokio::test]
|
|
async fn recover_keeps_dormant_sidecar_when_cwd_exists() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
|
|
let revived = revive(&fx);
|
|
revived.recover().await.expect("recover");
|
|
|
|
assert!(revived.has(s.id).await);
|
|
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
let snap = revived.snapshot().await;
|
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
|
}
|
|
|
|
/// #164: a fake [`maestro_daemon::boot_id::BootIdSource`] lets tests simulate
|
|
/// a changed (or unreadable) boot id without rebooting the machine.
|
|
#[derive(Debug)]
|
|
struct FakeBootId(Option<String>);
|
|
|
|
impl maestro_daemon::boot_id::BootIdSource for FakeBootId {
|
|
fn current(&self) -> Option<String> {
|
|
self.0.clone()
|
|
}
|
|
}
|
|
|
|
fn revive_with_boot_id(
|
|
fx: &DaemonFixture,
|
|
boot_id_path: PathBuf,
|
|
current: Option<&str>,
|
|
) -> maestro_daemon::SessionManager {
|
|
maestro_daemon::SessionManager::new(
|
|
Arc::new(fx.tmux.tmux.clone()),
|
|
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
|
fx.fifo_dir(),
|
|
)
|
|
.with_boot_id(
|
|
boot_id_path,
|
|
Arc::new(FakeBootId(current.map(String::from))),
|
|
)
|
|
}
|
|
|
|
/// #164: a changed boot id means every session from the previous boot is
|
|
/// already dead, so recovery must not trust tmux to say so — it skips the
|
|
/// probe outright. Proven by leaving the tmux session alive: if the probe
|
|
/// ran, it would come back `Running`, not `Dormant`.
|
|
#[tokio::test]
|
|
async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let boot_id_path = fx.state_dir.path().join("boot_id");
|
|
std::fs::write(&boot_id_path, "previous-boot").unwrap();
|
|
|
|
// tmux session is still alive — a probe would find it if one ran.
|
|
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 0, "reboot detected: nothing adopted live");
|
|
|
|
let snap = revived.snapshot().await;
|
|
assert_eq!(snap.len(), 1);
|
|
assert_eq!(snap[0].id, s.id);
|
|
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
|
|
|
// The current boot id is now the stored one, for the next comparison.
|
|
assert_eq!(
|
|
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
|
|
"current-boot"
|
|
);
|
|
}
|
|
|
|
/// #164: an unchanged boot id is a plain restart — recovery probes tmux as
|
|
/// before and adopts the still-live session `Running`.
|
|
#[tokio::test]
|
|
async fn recover_probes_tmux_when_boot_id_unchanged() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let boot_id_path = fx.state_dir.path().join("boot_id");
|
|
std::fs::write(&boot_id_path, "same-boot").unwrap();
|
|
|
|
let revived = revive_with_boot_id(&fx, boot_id_path, Some("same-boot"));
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 1);
|
|
let snap = revived.snapshot().await;
|
|
assert_eq!(snap[0].id, s.id);
|
|
assert_eq!(snap[0].status, SessionStatus::Running);
|
|
}
|
|
|
|
/// #164: no stored boot id (first run ever, or an older build that never
|
|
/// wrote one) means there is nothing to compare against — falls back to
|
|
/// today's probe-based recovery rather than assuming a reboot.
|
|
#[tokio::test]
|
|
async fn recover_falls_back_to_probe_when_no_stored_boot_id() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
fx.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let boot_id_path = fx.state_dir.path().join("boot_id");
|
|
assert!(!boot_id_path.exists());
|
|
|
|
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 1, "no stored boot id; falls back to probing tmux");
|
|
assert_eq!(
|
|
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
|
|
"current-boot"
|
|
);
|
|
}
|
|
|
|
/// #164: an unreadable current boot id (non-Linux, restricted `/proc`) falls
|
|
/// back to today's behaviour rather than failing startup or assuming a
|
|
/// reboot.
|
|
#[tokio::test]
|
|
async fn recover_falls_back_to_probe_when_boot_id_unreadable() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
fx.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let boot_id_path = fx.state_dir.path().join("boot_id");
|
|
std::fs::write(&boot_id_path, "stored-boot").unwrap();
|
|
|
|
let revived = revive_with_boot_id(&fx, boot_id_path, None);
|
|
let adopted = revived.recover().await.expect("recover");
|
|
assert_eq!(adopted, 1, "boot id unreadable; falls back to probing tmux");
|
|
}
|
|
|
|
/// #162: dismissing a dormant session is the one thing that can be done to it
|
|
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
|
|
/// does not resurrect it.
|
|
#[tokio::test]
|
|
async fn dormant_session_can_be_dismissed() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
|
|
|
let revived = revive(&fx);
|
|
revived.recover().await.expect("recover");
|
|
revived.kill(s.id, false).await.expect("dismiss");
|
|
|
|
assert!(!revived.has(s.id).await);
|
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
}
|
|
|
|
/// A second `SessionManager` over the same dirs and the same tmux server —
|
|
/// what a daemon restart looks like from inside a test.
|
|
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
|
|
maestro_daemon::SessionManager::new(
|
|
Arc::new(fx.tmux.tmux.clone()),
|
|
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
|
fx.fifo_dir(),
|
|
)
|
|
}
|
|
|
|
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
|
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
|
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
|
/// setup, so the child's first output was buffered by tmux but never tee'd
|
|
/// into the FIFO.
|
|
#[tokio::test]
|
|
async fn create_captures_initial_output_in_replay_buffer() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
// The shell prints the marker before doing anything else and
|
|
// then sleeps so the session stays alive. Before NAP-253 this
|
|
// marker was the canonical case that got swallowed.
|
|
command: "sh -c 'echo NAP_253_FIRST_LINE_MARKER; exec sleep 30'".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let want = b"NAP_253_FIRST_LINE_MARKER";
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
let mut found = false;
|
|
while std::time::Instant::now() < deadline {
|
|
let (replay, _rx) = fx
|
|
.manager
|
|
.subscribe_pty_with_replay(s.id)
|
|
.await
|
|
.expect("subscribe");
|
|
if replay.windows(want.len()).any(|w| w == want) {
|
|
found = true;
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
found,
|
|
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
|
|
);
|
|
|
|
fx.manager.kill(s.id, false).await.unwrap();
|
|
}
|
|
|
|
/// A fresh attach must land on the pane's *current* screen. Replaying the raw
|
|
/// byte tail instead reproduced a browser refresh painting garbage over an
|
|
/// agent TUI: the tail starts mid-stream, so the clears, modes and cursor
|
|
/// moves that gave those bytes meaning are long gone.
|
|
#[tokio::test]
|
|
async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
// Print a line, then switch to the alternate screen the way a
|
|
// full-screen program does on startup. Only the second marker is
|
|
// on the pane; the first belongs to the screen behind it.
|
|
command: "sh -c 'printf STALE_MARKER_LINE\\\\n; sleep 0.5; printf \"\\\\033[?1049h\\\\033[2J\\\\033[HLIVE_MARKER\"; exec sleep 30'"
|
|
.into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let want = b"LIVE_MARKER";
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
let mut replay = Vec::new();
|
|
while std::time::Instant::now() < deadline {
|
|
let (bytes, _rx) = fx
|
|
.manager
|
|
.subscribe_pty_with_replay(s.id)
|
|
.await
|
|
.expect("subscribe");
|
|
replay = bytes;
|
|
if replay.windows(want.len()).any(|w| w == want) {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
|
|
let text = String::from_utf8_lossy(&replay).into_owned();
|
|
assert!(
|
|
replay.starts_with(b"\x1b[?1049h\x1b[H\x1b[2J"),
|
|
"replay did not start with an alternate-screen snapshot: {text:?}"
|
|
);
|
|
assert!(
|
|
text.contains("LIVE_MARKER"),
|
|
"replay lost the live screen: {text:?}"
|
|
);
|
|
assert!(
|
|
!text.contains("STALE_MARKER_LINE"),
|
|
"replay carried bytes from behind the alternate screen: {text:?}"
|
|
);
|
|
|
|
fx.manager.kill(s.id, false).await.unwrap();
|
|
}
|
|
|
|
/// A full-screen app turns mouse reporting on once, at startup. A client that
|
|
/// attaches later never sees those sequences, so it reads the pane as a plain
|
|
/// alternate screen and its wheel and touch gestures have nowhere to go. The
|
|
/// snapshot must carry the pane's mouse modes with it.
|
|
#[tokio::test]
|
|
async fn attach_replay_carries_the_panes_mouse_modes() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
// Alternate screen, then button-event tracking (?1002) with SGR
|
|
// coordinates (?1006) — what tmux and agent TUIs actually request.
|
|
command: "sh -c 'printf \"\\\\033[?1049h\\\\033[?1002h\\\\033[?1006h\\\\033[2J\\\\033[HMOUSE_MARKER\"; exec sleep 30'"
|
|
.into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let want = b"MOUSE_MARKER";
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
let mut replay = Vec::new();
|
|
while std::time::Instant::now() < deadline {
|
|
let (bytes, _rx) = fx
|
|
.manager
|
|
.subscribe_pty_with_replay(s.id)
|
|
.await
|
|
.expect("subscribe");
|
|
replay = bytes;
|
|
if replay.windows(want.len()).any(|w| w == want) {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
|
|
let text = String::from_utf8_lossy(&replay).into_owned();
|
|
assert!(
|
|
text.contains("MOUSE_MARKER"),
|
|
"replay never reached the live screen: {text:?}"
|
|
);
|
|
assert!(
|
|
replay.starts_with(b"\x1b[?1049h\x1b[?1002h\x1b[?1006h\x1b[H\x1b[2J"),
|
|
"replay dropped the pane's mouse modes: {text:?}"
|
|
);
|
|
|
|
fx.manager.kill(s.id, false).await.unwrap();
|
|
}
|
|
|
|
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
|
/// subscribers. Before the fix tmux's stale `pipe-pane` (with `-o`) silently
|
|
/// kept feeding the old FIFO and the revived manager's subscribers saw
|
|
/// nothing.
|
|
#[tokio::test]
|
|
async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "bash --noprofile --norc".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// Capture paths + tmux handle before dropping the original manager —
|
|
// `drop(fx.manager)` partially moves `fx` so we can't call its helpers
|
|
// afterwards.
|
|
let sidecar_dir = fx.sidecar_dir();
|
|
let fifo_dir = fx.fifo_dir();
|
|
let tmux = fx.tmux.tmux.clone();
|
|
|
|
// Drop the original manager. Its PtyBridge closes the FIFO's only
|
|
// reader, so tmux's `cat >> fifo` writer hits SIGPIPE and exits — but
|
|
// tmux's pipe-pane bookkeeping still thinks the pane is piped. That's
|
|
// exactly the stale state recover() has to overwrite.
|
|
drop(fx.manager);
|
|
|
|
let sidecar = maestro_daemon::sidecar::SidecarStore::new(sidecar_dir);
|
|
let worktree_mgr = maestro_daemon::worktree::WorktreeManager::new(None);
|
|
let revived =
|
|
maestro_daemon::SessionManager::new(Arc::new(tmux), sidecar, worktree_mgr, fifo_dir);
|
|
assert_eq!(revived.recover().await.expect("recover"), 1);
|
|
|
|
let mut rx = revived.subscribe_pty(s.id).await.expect("subscribe");
|
|
revived
|
|
.send_keys(s.id, b"echo NAP_228_RECOVERED\n")
|
|
.await
|
|
.unwrap();
|
|
|
|
let want = b"NAP_228_RECOVERED";
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while std::time::Instant::now() < deadline {
|
|
if let Ok(Ok(bytes)) = tokio::time::timeout(Duration::from_millis(50), rx.recv()).await {
|
|
buf.extend_from_slice(&bytes);
|
|
if buf.windows(want.len()).any(|w| w == want) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert!(
|
|
buf.windows(want.len()).any(|w| w == want),
|
|
"recover() did not restore pty output flow; buf={:?}",
|
|
String::from_utf8_lossy(&buf),
|
|
);
|
|
|
|
revived.kill(s.id, false).await.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "bash --noprofile --norc".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut a = fx.manager.subscribe_pty(s.id).await.unwrap();
|
|
let mut b = fx.manager.subscribe_pty(s.id).await.unwrap();
|
|
|
|
fx.manager
|
|
.send_keys(s.id, b"echo MAESTRO_BROADCAST_OK\n")
|
|
.await
|
|
.unwrap();
|
|
|
|
let want = b"MAESTRO_BROADCAST_OK";
|
|
let mut buf_a: Vec<u8> = Vec::new();
|
|
let mut buf_b: Vec<u8> = Vec::new();
|
|
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
|
while std::time::Instant::now() < deadline {
|
|
if let Ok(Ok(bytes)) = tokio::time::timeout(Duration::from_millis(50), a.recv()).await {
|
|
buf_a.extend_from_slice(&bytes);
|
|
}
|
|
if let Ok(Ok(bytes)) = tokio::time::timeout(Duration::from_millis(50), b.recv()).await {
|
|
buf_b.extend_from_slice(&bytes);
|
|
}
|
|
if buf_a.windows(want.len()).any(|w| w == want)
|
|
&& buf_b.windows(want.len()).any(|w| w == want)
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
|
|
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
|
|
|
|
fx.manager.kill(s.id, false).await.unwrap();
|
|
let _ = (cwd, PathBuf::from("/"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// Subscribe *after* the create so we don't have to filter the Added event.
|
|
let mut events = fx.manager.events();
|
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
|
|
|
// Kill the tmux session out from under the daemon. This is the scenario
|
|
// the watcher exists for: command exits, tmux destroys the session, the
|
|
// daemon never finds out through its normal channels.
|
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
|
|
|
wait_for_async(Duration::from_secs(3), || async {
|
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
|
})
|
|
.await;
|
|
|
|
// Marked, not removed — the operator still sees it exited, and its
|
|
// artifacts stay so the output can be replayed.
|
|
assert!(fx.manager.has(s.id).await);
|
|
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
|
|
let mut saw_dead = false;
|
|
let deadline = std::time::Instant::now() + Duration::from_secs(1);
|
|
while std::time::Instant::now() < deadline && !saw_dead {
|
|
match tokio::time::timeout(Duration::from_millis(50), events.recv()).await {
|
|
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
|
if session.id == s.id && session.status == SessionStatus::Dead =>
|
|
{
|
|
saw_dead = true
|
|
}
|
|
Ok(Ok(_)) => continue,
|
|
_ => break,
|
|
}
|
|
}
|
|
assert!(
|
|
saw_dead,
|
|
"expected a SessionUpdated marking the session dead"
|
|
);
|
|
|
|
// Dismissing it is what finally releases the artifacts.
|
|
fx.manager.kill(s.id, false).await.expect("dismiss");
|
|
assert!(!fx.manager.has(s.id).await);
|
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
|
}
|
|
|
|
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
|
|
/// first "nothing blocks" for a pristine tree, then the breakdown once the
|
|
/// tree picks up unsaved work, so clients can warn before the kill attempt.
|
|
#[tokio::test]
|
|
async fn watcher_refreshes_the_blocked_work_indicator() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
|
|
|
|
fx.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/indicator".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// Pushed and clean: nothing in the way, and the indicator must say so.
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
// Subscribe before the watcher starts so every pass's emission is
|
|
// captured — otherwise the breakdown event can land between the
|
|
// snapshot observation below and the drain, and the quiet window
|
|
// would see it as an illegitimate re-emit.
|
|
let mut events = fx.manager.events();
|
|
|
|
fx.manager.start_liveness_watcher_with_blocked_work(
|
|
Duration::from_millis(50),
|
|
Duration::from_millis(100),
|
|
);
|
|
|
|
wait_for_async(Duration::from_secs(5), || async {
|
|
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
|
|
})
|
|
.await;
|
|
|
|
// Unsaved work appears; the next pass must surface it.
|
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
|
wait_for_async(Duration::from_secs(5), || async {
|
|
matches!(
|
|
&fx.manager.snapshot().await[0].blocked_work,
|
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
|
)
|
|
})
|
|
.await;
|
|
|
|
// Consume through the emission that carried the breakdown (#83
|
|
// change-detection): the quiet window below must start after the last
|
|
// legitimate change, not race it into the channel.
|
|
loop {
|
|
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
|
|
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
|
if matches!(
|
|
&session.blocked_work,
|
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
|
) =>
|
|
{
|
|
break;
|
|
}
|
|
Ok(Ok(_)) => continue,
|
|
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
|
|
}
|
|
}
|
|
while events.try_recv().is_ok() {}
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
|
|
// The activity pass may still legitimately announce e.g. Active→Idle
|
|
// (its debounce is time-based, not worktree-based), and its payload
|
|
// carries today's blocked_work unchanged.
|
|
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
|
assert!(
|
|
matches!(
|
|
&session.blocked_work,
|
|
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
|
),
|
|
"an unchanged worktree must not re-emit a changed indicator"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
|
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
|
/// nothing in the tree is unique and removal goes ahead.
|
|
#[tokio::test]
|
|
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("keep");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/keep".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
assert!(wt_path.exists());
|
|
|
|
// Push the branch so its commits are reachable from a remote-tracking ref.
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
fx.manager.kill(s.id, false).await.expect("kill");
|
|
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
|
}
|
|
|
|
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
|
/// kill outright. Nothing happens — the session keeps running as the
|
|
/// operator's handle on their unfinished work, and the tree is untouched.
|
|
/// The refusal names what blocked it and how much of it there is.
|
|
#[tokio::test]
|
|
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("guarded");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/guarded".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
|
|
|
let err = fx
|
|
.manager
|
|
.kill(s.id, false)
|
|
.await
|
|
.expect_err("kill must refuse");
|
|
let SessionError::KillGuarded(breakdown) = err else {
|
|
panic!("expected KillGuarded, got {err:?}");
|
|
};
|
|
assert_eq!(breakdown.uncommitted_files, 1);
|
|
assert_eq!(breakdown.untracked_files, 1);
|
|
assert!(
|
|
breakdown.unpushed_commits >= 1,
|
|
"a fresh branch has no remote-tracking ref"
|
|
);
|
|
let message = breakdown.to_string();
|
|
assert!(message.contains("1 uncommitted file"), "{message}");
|
|
assert!(message.contains("1 untracked file"), "{message}");
|
|
|
|
// Nothing happened: the session survives and so does the tree.
|
|
assert!(fx.manager.has(s.id).await, "the session must keep running");
|
|
assert!(wt_path.exists(), "the worktree must be untouched");
|
|
|
|
// A second kill after the work is saved goes through.
|
|
run_git_in(&wt_path, &["add", "."]);
|
|
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
fx.manager
|
|
.kill(s.id, false)
|
|
.await
|
|
.expect("kill after saving");
|
|
assert!(!fx.manager.has(s.id).await);
|
|
}
|
|
|
|
/// Unpushed commits alone block the kill (#79): a feature branch almost
|
|
/// always has them, which is why `--force` (which retains) is the ordinary
|
|
/// way to end such a session.
|
|
#[tokio::test]
|
|
async fn killing_refuses_until_the_branch_is_pushed() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/pushed".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let err = fx
|
|
.manager
|
|
.kill(s.id, false)
|
|
.await
|
|
.expect_err("unpushed blocks");
|
|
let SessionError::KillGuarded(breakdown) = err else {
|
|
panic!("expected KillGuarded, got {err:?}");
|
|
};
|
|
assert_eq!(breakdown.uncommitted_files, 0);
|
|
assert_eq!(breakdown.untracked_files, 0);
|
|
assert!(breakdown.unpushed_commits >= 1);
|
|
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
fx.manager
|
|
.kill(s.id, false)
|
|
.await
|
|
.expect("pushed branch kills");
|
|
assert!(!wt_path.exists());
|
|
assert!(!fx.manager.has(s.id).await);
|
|
}
|
|
|
|
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
|
/// but the guarded worktree is left on disk — never removed.
|
|
#[tokio::test]
|
|
async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("forced");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/forced".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
|
|
// Without force the same kill is refused (#79).
|
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
|
|
|
fx.manager
|
|
.kill(s.id, true)
|
|
.await
|
|
.expect("force overrides the refusal");
|
|
|
|
// The tree would have blocked, so force must NOT have removed it...
|
|
assert!(
|
|
wt_path.exists(),
|
|
"a forced kill never removes a tree that would have blocked"
|
|
);
|
|
// ...but the session itself still ends.
|
|
assert!(!fx.manager.has(s.id).await);
|
|
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
|
}
|
|
|
|
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
|
/// guard is clear never needed the override, so a forced kill removes it as
|
|
/// usual.
|
|
#[tokio::test]
|
|
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("clear");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/clear".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
fx.manager.kill(s.id, true).await.expect("kill");
|
|
assert!(!wt_path.exists(), "a clear tree needs no override");
|
|
}
|
|
|
|
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
|
/// every Rust checkout, and an ignored-only worktree stays killable.
|
|
#[tokio::test]
|
|
async fn killing_an_ignored_only_worktree_succeeds() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
// .gitignore must be committed before the worktree branches off, or it
|
|
// would itself read as an untracked file.
|
|
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
|
|
run_git_in(repo.path(), &["add", ".gitignore"]);
|
|
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/rusty".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
std::fs::create_dir_all(wt_path.join("target")).unwrap();
|
|
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
|
|
|
|
// Branch pushed; the only dirt is ignored output.
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
fx.manager
|
|
.kill(s.id, false)
|
|
.await
|
|
.expect("ignored files never block");
|
|
assert!(!wt_path.exists());
|
|
assert!(!fx.manager.has(s.id).await);
|
|
}
|
|
|
|
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
|
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
|
/// unsaved work is LEFT ON DISK and stays attached to the dead record; the
|
|
/// session stays in the catalog marked dead until dismissed, and dismissing
|
|
/// it goes through `--force`, which leaves the tree alone too.
|
|
#[tokio::test]
|
|
async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/exited".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
|
|
|
// The command finishes / the operator types exit: tmux loses the session.
|
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
|
wait_for_async(Duration::from_secs(3), || async {
|
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
|
})
|
|
.await;
|
|
|
|
// The settle step has been applied or refused either way — here refused,
|
|
// since the tree holds unsaved work. Only from this point is "the tree
|
|
// survived" a settled fact rather than a race (#115).
|
|
wait_for_async(Duration::from_secs(3), || async {
|
|
fx.manager.is_settled(s.id).await == Some(true)
|
|
})
|
|
.await;
|
|
|
|
// The tree would have blocked a kill, so the exit kept it.
|
|
assert!(
|
|
wt_path.exists(),
|
|
"a dirty worktree must be left alone on natural exit"
|
|
);
|
|
let snap = fx.manager.snapshot().await;
|
|
assert_eq!(snap.len(), 1);
|
|
assert!(
|
|
snap[0].worktree.is_some(),
|
|
"the tree stays attached to the dead record"
|
|
);
|
|
|
|
// Dismissing it needs --force, and force never removes what would block.
|
|
assert!(fx.manager.kill(s.id, false).await.is_err());
|
|
fx.manager.kill(s.id, true).await.expect("forced dismissal");
|
|
assert!(!fx.manager.has(s.id).await);
|
|
assert!(wt_path.exists(), "dismissal must not remove the work");
|
|
}
|
|
|
|
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
|
/// nothing to keep — the worktree is removed.
|
|
#[tokio::test]
|
|
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/clean".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
// Push everything so the branch has no unpushed commits: fully pristine.
|
|
let origin = TempDir::new().unwrap();
|
|
init_bare_repo(origin.path());
|
|
push_all_to_origin(repo.path(), origin.path());
|
|
|
|
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
|
wait_for_async(Duration::from_secs(3), || async {
|
|
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
|
})
|
|
.await;
|
|
|
|
// (`Dead` is observable a moment before the settle step removes the tree.)
|
|
wait_for_async(Duration::from_secs(3), || async { !wt_path.exists() }).await;
|
|
|
|
assert!(
|
|
!wt_path.exists(),
|
|
"a pristine worktree leaves nothing behind"
|
|
);
|
|
assert!(
|
|
fx.manager.has(s.id).await,
|
|
"the session stays until dismissed"
|
|
);
|
|
}
|
|
|
|
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
|
let out = Command::new("git")
|
|
.arg("-C")
|
|
.arg(dir)
|
|
.args(args)
|
|
.output()
|
|
.expect("git spawn");
|
|
assert!(
|
|
out.status.success(),
|
|
"git {args:?} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn liveness_watcher_leaves_running_sessions_alone() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
|
|
|
// Give the watcher enough time to run a handful of ticks.
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
assert!(fx.manager.has(s.id).await, "live session got reaped");
|
|
|
|
fx.manager.kill(s.id, false).await.unwrap();
|
|
}
|
|
|
|
/// #99: clients group a session under `worktree.base_repo`, so that field has
|
|
/// to name the repository even when the spawn request pointed at a linked
|
|
/// worktree. Recording the request verbatim put the session in a folder of its
|
|
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
|
|
#[tokio::test]
|
|
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
|
let fx = DaemonFixture::new();
|
|
let repo = TempDir::new().unwrap();
|
|
init_repo(repo.path());
|
|
|
|
// A linked worktree of that repo, then a spawn whose `base_repo` names it
|
|
// rather than the main checkout — what happens when the operator spawns
|
|
// from inside a worktree.
|
|
let first = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: repo.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: repo.path().to_path_buf(),
|
|
branch: "feat/one".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create first");
|
|
let inner_worktree = first.worktree.expect("first has a worktree").path;
|
|
|
|
let second = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: inner_worktree.clone(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: Some(WorktreeSpec {
|
|
base_repo: inner_worktree.clone(),
|
|
branch: "feat/two".into(),
|
|
create_branch: true,
|
|
}),
|
|
})
|
|
.await
|
|
.expect("create second");
|
|
|
|
let wt = second.worktree.expect("second has a worktree");
|
|
assert_eq!(
|
|
wt.base_repo,
|
|
repo.path()
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| repo.path().to_path_buf()),
|
|
"base_repo must be the repository, not the worktree the request named",
|
|
);
|
|
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
|
|
}
|
|
|
|
/// #103: every tmux-spawned session carries the daemon's identity variables in
|
|
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
|
|
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
|
|
/// both without any out-of-band channel.
|
|
#[tokio::test]
|
|
async fn spawned_sessions_export_maestro_session_id_and_url() {
|
|
let fx = DaemonFixture::new();
|
|
fx.manager
|
|
.set_daemon_url("http://127.0.0.1:7071".to_string());
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let env = fx
|
|
.tmux
|
|
.tmux
|
|
.show_environment(s.id)
|
|
.expect("show-environment");
|
|
assert_eq!(
|
|
env.get("MAESTRO_SESSION_ID"),
|
|
Some(&s.id.to_string()),
|
|
"session env: {env:?}"
|
|
);
|
|
assert_eq!(
|
|
env.get("MAESTRO_URL"),
|
|
Some(&"http://127.0.0.1:7071".to_string())
|
|
);
|
|
|
|
fx.manager.kill(s.id, false).await.expect("kill");
|
|
}
|
|
|
|
/// docs/COMMAND-BAR.md, empty-query ordering: a session that has never been
|
|
/// focused carries no `last_focused_at` at all, which is what lets a client
|
|
/// sort it after every session that has one (#145).
|
|
#[tokio::test]
|
|
async fn a_freshly_created_session_has_never_been_focused() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
assert_eq!(s.last_focused_at, None);
|
|
assert_eq!(fx.manager.snapshot().await[0].last_focused_at, None);
|
|
}
|
|
|
|
/// #145: `POST /.../focus` reporting `focused: true` stamps
|
|
/// `last_focused_at` and announces it on the event stream; reporting
|
|
/// `focused: false` (losing focus) leaves the stamp alone, since the bar
|
|
/// orders by when the operator last arrived, not when they left.
|
|
#[tokio::test]
|
|
async fn set_focused_true_stamps_last_focused_at() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
|
|
let mut events = fx.manager.events();
|
|
let before = chrono::Utc::now();
|
|
fx.manager.set_focused(s.id, true).await.expect("focus");
|
|
|
|
let updated = fx.manager.snapshot().await[0].clone();
|
|
let stamp = updated.last_focused_at.expect("stamped on focus");
|
|
assert!(stamp >= before);
|
|
|
|
let mut saw_update = false;
|
|
while let Ok(event) = tokio::time::timeout(Duration::from_millis(200), events.recv()).await {
|
|
if let Ok(CatalogEvent::SessionUpdated { session }) = event {
|
|
if session.id == s.id && session.last_focused_at == Some(stamp) {
|
|
saw_update = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
assert!(saw_update, "expected a SessionUpdated carrying the stamp");
|
|
|
|
fx.manager.set_focused(s.id, false).await.expect("unfocus");
|
|
let after_unfocus = fx.manager.snapshot().await[0].clone();
|
|
assert_eq!(
|
|
after_unfocus.last_focused_at,
|
|
Some(stamp),
|
|
"losing focus must not clear the last-focused stamp"
|
|
);
|
|
}
|
|
|
|
/// #145: opening `/api/attach/:id` is focusing too — it must stamp the same
|
|
/// field `set_focused` does, so a client that only ever attaches sorts
|
|
/// identically to one that reports focus explicitly.
|
|
#[tokio::test]
|
|
async fn attaching_stamps_last_focused_at() {
|
|
let fx = DaemonFixture::new();
|
|
let cwd = TempDir::new().unwrap();
|
|
|
|
let s = fx
|
|
.manager
|
|
.create(CreateSessionRequest {
|
|
cwd: cwd.path().to_path_buf(),
|
|
command: "sleep 30".into(),
|
|
env: HashMap::new(),
|
|
metadata: HashMap::new(),
|
|
worktree: None,
|
|
})
|
|
.await
|
|
.expect("create");
|
|
assert_eq!(s.last_focused_at, None);
|
|
|
|
let before = chrono::Utc::now();
|
|
let (_replay, _rx) = fx
|
|
.manager
|
|
.subscribe_pty_with_replay(s.id)
|
|
.await
|
|
.expect("known session");
|
|
|
|
let updated = fx.manager.snapshot().await[0].clone();
|
|
let stamp = updated.last_focused_at.expect("attach stamps focus");
|
|
assert!(stamp >= before);
|
|
}
|