Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60655a1843 | ||
|
|
2c0b96411c | ||
|
|
8f1b234a11 | ||
|
|
ec7728afb1 | ||
|
|
0fd107be77 | ||
|
|
aebd10c2da | ||
|
|
ae3091e2a2 | ||
|
|
1366c2d430 | ||
|
|
d050df7d3d | ||
|
|
e51a7007a6 | ||
|
|
02bf077c03 |
@@ -129,6 +129,80 @@ pub struct DaemonSection {
|
||||
/// `Some(0)` disables history entirely (no log written, no recents read).
|
||||
#[serde(default)]
|
||||
pub spawn_history_days: Option<u32>,
|
||||
/// What recovery does with sessions that outlived their tmux server —
|
||||
/// after a host reboot, or any restart that finds a sidecar with no pane
|
||||
/// (#167). Default [`ResumeOnBoot::Dormant`].
|
||||
#[serde(default, deserialize_with = "deserialize_resume_on_boot")]
|
||||
pub resume_on_boot: Option<ResumeOnBoot>,
|
||||
}
|
||||
|
||||
impl DaemonSection {
|
||||
/// The configured `[daemon].resume_on_boot`, or the safe default:
|
||||
/// recovered-but-dead sessions wait for the operator.
|
||||
pub fn resume_on_boot(&self) -> ResumeOnBoot {
|
||||
self.resume_on_boot.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `[daemon].resume_on_boot` — what recovery does with a session whose tmux
|
||||
/// pane is gone but whose sidecar is still on disk (#167).
|
||||
///
|
||||
/// Resuming agents unattended seconds after a reboot is a foot-gun: they run
|
||||
/// with whatever flags they were spawned with, with nobody watching. So the
|
||||
/// default waits for a human, and `auto` is opted into per host.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ResumeOnBoot {
|
||||
/// Register each one `Dormant` and wait for the operator to resume it.
|
||||
#[default]
|
||||
Dormant,
|
||||
/// Resume each one during recovery, on the same path a client's
|
||||
/// `POST /api/sessions/:id/resume` takes.
|
||||
Auto,
|
||||
/// Prune the sidecars instead: a reboot is a clean slate.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Every `[daemon].resume_on_boot` name and its variant, in documentation
|
||||
/// order.
|
||||
pub const RESUME_ON_BOOT_POLICIES: &[(&str, ResumeOnBoot)] = &[
|
||||
("dormant", ResumeOnBoot::Dormant),
|
||||
("auto", ResumeOnBoot::Auto),
|
||||
("never", ResumeOnBoot::Never),
|
||||
];
|
||||
|
||||
impl ResumeOnBoot {
|
||||
/// The name this policy is spelled with in the config file and in logs.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ResumeOnBoot::Dormant => "dormant",
|
||||
ResumeOnBoot::Auto => "auto",
|
||||
ResumeOnBoot::Never => "never",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_resume_on_boot<'de, D>(deserializer: D) -> Result<Option<ResumeOnBoot>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<String>::deserialize(deserializer)?;
|
||||
raw.map(|name| {
|
||||
RESUME_ON_BOOT_POLICIES
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, policy)| *policy)
|
||||
.ok_or_else(|| {
|
||||
let known = RESUME_ON_BOOT_POLICIES
|
||||
.iter()
|
||||
.map(|(n, _)| format!("{n:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
D::Error::custom(format!(
|
||||
"invalid [daemon].resume_on_boot {name:?}; expected one of {known}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
||||
@@ -775,4 +849,27 @@ bind_addr = "127.0.0.1:9090"
|
||||
);
|
||||
assert_eq!(cfg.resolve_daemon_url(None), "http://explicit:1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_on_boot_defaults_to_dormant_and_parses_every_policy() {
|
||||
assert_eq!(
|
||||
MaestroConfigFile::default().daemon.resume_on_boot(),
|
||||
ResumeOnBoot::Dormant,
|
||||
"an absent policy is the safe one"
|
||||
);
|
||||
for (name, expected) in RESUME_ON_BOOT_POLICIES {
|
||||
let cfg = file_with(&format!("[daemon]\nresume_on_boot = \"{name}\"\n"));
|
||||
assert_eq!(cfg.daemon.resume_on_boot(), *expected, "{name}");
|
||||
assert_eq!(expected.as_str(), *name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_on_boot_rejects_an_unknown_policy() {
|
||||
let err = toml::from_str::<MaestroConfigFile>("[daemon]\nresume_on_boot = \"maybe\"\n")
|
||||
.expect_err("unknown policy must not silently fall back");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("resume_on_boot"), "{msg}");
|
||||
assert!(msg.contains("\"auto\""), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//!
|
||||
//! `/proc/sys/kernel/random/boot_id` is fixed for the life of the kernel and
|
||||
//! changes on every boot. [`SessionManager::recover`] compares it against the
|
||||
//! value it stored on the previous run: a difference means every session from
|
||||
//! before is provably dead, so the tmux probe can be skipped and those
|
||||
//! sessions registered dormant directly instead of relying on tmux reporting
|
||||
//! an empty list.
|
||||
//! value it stored on the previous run and logs which of the two it was.
|
||||
//!
|
||||
//! It does not decide liveness (#174). A boot id changes without the tmux
|
||||
//! server dying whenever the state directory is restored, a VM or container
|
||||
//! snapshot is rolled back, or the `boot_id` file is truncated — so recovery
|
||||
//! always probes `tmux list-sessions` and the boot id only says whether a
|
||||
//! server that did not answer is coming back.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -87,7 +87,10 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
.with_boot_id(
|
||||
config.boot_id_path(),
|
||||
std::sync::Arc::new(crate::boot_id::ProcBootId),
|
||||
);
|
||||
)
|
||||
// #167: recovery needs the presets to resolve a resumed session's command,
|
||||
// so `auto` runs the same resume a client's POST would.
|
||||
.with_resume_policy(config.resume_on_boot, config.spawn_presets.clone());
|
||||
// Spawned sessions learn where to report back (#103). Derived from the
|
||||
// configured bind address; set before any spawn can happen.
|
||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||
|
||||
@@ -97,6 +97,10 @@ pub struct DaemonConfig {
|
||||
/// never binds it itself; it serves it so the web client and the TUI read
|
||||
/// one knob.
|
||||
pub leader: String,
|
||||
/// What recovery does with sessions whose tmux pane is gone but whose
|
||||
/// sidecar survives (#167): `[daemon].resume_on_boot`, default
|
||||
/// [`maestro_config::ResumeOnBoot::Dormant`].
|
||||
pub resume_on_boot: maestro_config::ResumeOnBoot,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -149,6 +153,8 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
// validation (#22/#26).
|
||||
let spawn_presets = file.spawn.resolve()?;
|
||||
|
||||
let resume_on_boot = daemon.resume_on_boot();
|
||||
|
||||
let hostname = daemon.hostname.unwrap_or_else(system_hostname);
|
||||
let home_dir = dirs::home_dir()
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
@@ -209,6 +215,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
leader: file.tui.leader().to_string(),
|
||||
resume_on_boot,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,6 +296,34 @@ auto_install = false
|
||||
assert!(!cfg.hooks_auto_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_on_boot_defaults_to_dormant_and_reads_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
assert_eq!(
|
||||
load_config(&path, EnvOverrides::default())
|
||||
.unwrap()
|
||||
.resume_on_boot,
|
||||
maestro_config::ResumeOnBoot::Dormant
|
||||
);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[daemon]
|
||||
resume_on_boot = "auto"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
load_config(&path, EnvOverrides::default())
|
||||
.unwrap()
|
||||
.resume_on_boot,
|
||||
maestro_config::ResumeOnBoot::Auto
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_defaults_everything() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -12,6 +12,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_config::ResumeOnBoot;
|
||||
use maestro_protocol::{
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
|
||||
SessionStatus, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
|
||||
@@ -86,6 +87,11 @@ pub struct SessionRecord {
|
||||
/// (`POST /.../focus`), `None` while unfocused. Daemon-local; never rides
|
||||
/// the wire.
|
||||
pub focused_since: Option<std::time::Instant>,
|
||||
/// Set for as long as a [`SessionManager::resume`] is between its two
|
||||
/// takes of the map lock (#175). The spawn happens with the lock released,
|
||||
/// so the `Running` check alone lets a second request through; this is the
|
||||
/// state the second one is refused on. Daemon-local; never rides the wire.
|
||||
pub resuming: bool,
|
||||
/// Sticky acknowledgement applied by [`crate::activity::apply_focus_suppression`]:
|
||||
/// once the pane has been focused long enough to clear an `AwaitingInput`
|
||||
/// glow, this holds through focus loss until the session does new work.
|
||||
@@ -195,6 +201,11 @@ struct Inner {
|
||||
/// reboot detection is unwired, which behaves exactly like a boot id
|
||||
/// that could never be read: always fall back to probing tmux.
|
||||
boot_id: Option<BootIdConfig>,
|
||||
/// What recovery does with a session whose pane is gone (#167), and the
|
||||
/// presets an `auto` resume resolves its command from. Defaults to
|
||||
/// [`ResumeOnBoot::Dormant`] with no presets: register and wait.
|
||||
resume_policy: ResumeOnBoot,
|
||||
resume_presets: Vec<maestro_protocol::SpawnPreset>,
|
||||
}
|
||||
|
||||
/// Where recovery persists the boot id it last saw, and how it reads the
|
||||
@@ -253,6 +264,8 @@ impl SessionManager {
|
||||
viewports: Mutex::new(Viewports::new()),
|
||||
next_viewport: AtomicU64::new(0),
|
||||
boot_id: None,
|
||||
resume_policy: ResumeOnBoot::default(),
|
||||
resume_presets: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -297,6 +310,26 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wire the `[daemon].resume_on_boot` policy (#167) and the spawn presets
|
||||
/// an `auto` resume resolves each session's command from. Builder, called
|
||||
/// once right after construction like the other `with_*` methods. Without
|
||||
/// it, recovery behaves as [`ResumeOnBoot::Dormant`].
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the handle has already been cloned.
|
||||
pub fn with_resume_policy(
|
||||
mut self,
|
||||
policy: ResumeOnBoot,
|
||||
presets: Vec<maestro_protocol::SpawnPreset>,
|
||||
) -> Self {
|
||||
let inner = Arc::get_mut(&mut self.inner)
|
||||
.expect("with_resume_policy must be called before the SessionManager is cloned");
|
||||
inner.resume_policy = policy;
|
||||
inner.resume_presets = presets;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the daemon's own HTTP base URL (#103), exported to every session
|
||||
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
|
||||
/// is a plain setter: the daemon runner learns its bound address after
|
||||
@@ -567,6 +600,7 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -618,15 +652,23 @@ impl SessionManager {
|
||||
id: Uuid,
|
||||
presets: &[maestro_protocol::SpawnPreset],
|
||||
) -> Result<Session, SessionError> {
|
||||
// The spawn below runs with the map lock released, so the `Running`
|
||||
// check alone is not enough to keep two resumes apart (#175): both
|
||||
// would pass it, both would open a `PtyBridge` on the same FIFO, and
|
||||
// the loser would report a tmux failure instead of the conflict it
|
||||
// is. The record is marked in-flight under this first lock, and every
|
||||
// path out of here clears it again.
|
||||
let mut session = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if rec.session.status == SessionStatus::Running {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if rec.session.status == SessionStatus::Running || rec.resuming {
|
||||
return Err(SessionError::AlreadyRunning(id));
|
||||
}
|
||||
rec.resuming = true;
|
||||
rec.session.clone()
|
||||
};
|
||||
if !session.cwd.exists() {
|
||||
self.clear_resuming(id).await;
|
||||
return Err(SessionError::CwdMissing(session.cwd.clone()));
|
||||
}
|
||||
let command = crate::resume::resume_command(&session, presets);
|
||||
@@ -658,6 +700,7 @@ impl SessionManager {
|
||||
if let Err(e) = spawned {
|
||||
// `bridge` drops here, closing the FIFO reader. Nothing else was
|
||||
// touched, so the session stays exactly as dormant as it was.
|
||||
self.clear_resuming(id).await;
|
||||
return Err(SessionError::Tmux(e));
|
||||
}
|
||||
|
||||
@@ -677,6 +720,7 @@ impl SessionManager {
|
||||
// sidecar does not describe.
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
self.clear_resuming(id).await;
|
||||
return Err(SessionError::Sidecar(e));
|
||||
}
|
||||
|
||||
@@ -684,9 +728,17 @@ impl SessionManager {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let Some(rec) = sessions.get_mut(&id) else {
|
||||
// Killed while we were spawning. Undo the pane instead of
|
||||
// leaving one behind with no record of it.
|
||||
// leaving one behind with no record of it — and delete the
|
||||
// sidecar the write above just put back (#175). The kill
|
||||
// deleted it before that write landed, so leaving it would
|
||||
// have the next recovery register a dormant ghost for a
|
||||
// session that is gone and whose worktree is already removed.
|
||||
drop(sessions);
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
if let Err(e) = self.inner.sidecar.delete(id) {
|
||||
tracing::warn!(error = ?e, %id, "resume: failed to delete the sidecar of a session killed mid-resume");
|
||||
}
|
||||
return Err(SessionError::NotFound(id));
|
||||
};
|
||||
rec.session = session.clone();
|
||||
@@ -696,6 +748,7 @@ impl SessionManager {
|
||||
rec.settled = false;
|
||||
rec.focused_since = None;
|
||||
rec.awaiting_suppressed = false;
|
||||
rec.resuming = false;
|
||||
}
|
||||
let _ = self.inner.events.send(CatalogEvent::SessionUpdated {
|
||||
session: session.clone(),
|
||||
@@ -703,6 +756,16 @@ impl SessionManager {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Drop the in-flight marker [`Self::resume`] set under its first lock
|
||||
/// (#175), so a refused or failed resume can be retried. A no-op when the
|
||||
/// record is already gone: a kill that landed mid-resume took the marker
|
||||
/// with it.
|
||||
async fn clear_resuming(&self, id: Uuid) {
|
||||
if let Some(rec) = self.inner.sessions.lock().await.get_mut(&id) {
|
||||
rec.resuming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort removal of a worktree created earlier in `create` when a
|
||||
/// later step fails. No-op when the request had no worktree.
|
||||
fn rollback_worktree(&self, base: &Option<PathBuf>, path: &Option<PathBuf>) {
|
||||
@@ -819,20 +882,20 @@ impl SessionManager {
|
||||
/// that no longer exists are still adopted — the spec does not require
|
||||
/// us to repair them.
|
||||
///
|
||||
/// #164: when the kernel's boot id has changed since the last run, the
|
||||
/// tmux probe below is skipped entirely — a reboot kills the tmux server,
|
||||
/// so every previous session is already dead and there is nothing tmux
|
||||
/// could truthfully adopt. [`Self::register_dormant`] then registers all
|
||||
/// of them directly.
|
||||
/// #164/#174: the kernel's boot id says whether the host rebooted since
|
||||
/// the last run, but it never decides which sessions are alive —
|
||||
/// `tmux list-sessions` does, always, which is what the rest of the
|
||||
/// daemon assumes. A boot id can change without the tmux server dying
|
||||
/// (a restored state directory, a VM or container snapshot restore, a
|
||||
/// truncated `boot_id` file); skipping the probe then registered every
|
||||
/// live session `Dormant`, and resume could never bring one back because
|
||||
/// the tmux name was still taken. The boot id is now only a signal that a
|
||||
/// tmux server which did not answer is not coming back, and it names
|
||||
/// itself in recovery's log.
|
||||
pub async fn recover(&self) -> Result<usize, SessionError> {
|
||||
tokio::fs::create_dir_all(&self.inner.fifo_dir).await?;
|
||||
let rebooted = self.check_boot_id().await;
|
||||
let live = if rebooted {
|
||||
// #164: every session from the previous boot is provably dead —
|
||||
// no tmux server survives a reboot — so there is nothing to gain
|
||||
// from asking it and no session it could honestly report back.
|
||||
Vec::new()
|
||||
} else {
|
||||
let live = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
tmux_blocking(move || tmux.list_sessions()).await?
|
||||
};
|
||||
@@ -894,6 +957,7 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -912,7 +976,9 @@ impl SessionManager {
|
||||
///
|
||||
/// A missing stored value (first run) or an unreadable current one
|
||||
/// (non-Linux, restricted `/proc`, unwired in this build) both read as
|
||||
/// "no reboot", which is today's behaviour: fall back to probing tmux.
|
||||
/// "no reboot". Either way the tmux probe runs (#174): the answer only
|
||||
/// tells recovery whether a tmux server that did not answer is coming
|
||||
/// back, never which sessions are alive.
|
||||
async fn check_boot_id(&self) -> bool {
|
||||
let Some(cfg) = &self.inner.boot_id else {
|
||||
return false;
|
||||
@@ -959,7 +1025,8 @@ impl SessionManager {
|
||||
/// `rebooted` (#164) only changes what gets logged: a detected reboot
|
||||
/// names the count even when it is zero, so the operator sees that
|
||||
/// recovery told a reboot from a restart instead of a log that stays
|
||||
/// silent either way.
|
||||
/// silent either way. It never decides membership — everything here got
|
||||
/// here by tmux not holding it (#174).
|
||||
async fn register_dormant(&self, rebooted: bool) {
|
||||
let stored = match self.inner.sidecar.load_all() {
|
||||
Ok(stored) => stored,
|
||||
@@ -970,6 +1037,7 @@ impl SessionManager {
|
||||
};
|
||||
let mut registered = Vec::new();
|
||||
let mut pruned = 0usize;
|
||||
let mut discarded = 0usize;
|
||||
{
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
for mut session in stored {
|
||||
@@ -984,6 +1052,16 @@ impl SessionManager {
|
||||
pruned += 1;
|
||||
continue;
|
||||
}
|
||||
if self.inner.resume_policy == ResumeOnBoot::Never {
|
||||
// #167: this host wants a reboot to be a clean slate, so
|
||||
// the sidecar goes the way #163's does — nothing is
|
||||
// registered and the next recovery finds nothing.
|
||||
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
||||
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar under resume_on_boot=never");
|
||||
}
|
||||
discarded += 1;
|
||||
continue;
|
||||
}
|
||||
let id = session.id;
|
||||
session.status = SessionStatus::Dormant;
|
||||
session.foreground = None;
|
||||
@@ -1006,6 +1084,7 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
registered.push(session);
|
||||
@@ -1025,12 +1104,45 @@ impl SessionManager {
|
||||
if pruned > 0 {
|
||||
tracing::info!(pruned, "pruned sidecars whose cwd no longer exists");
|
||||
}
|
||||
let resumable: Vec<Uuid> = registered.iter().map(|s| s.id).collect();
|
||||
for session in registered {
|
||||
let _ = self
|
||||
.inner
|
||||
.events
|
||||
.send(CatalogEvent::SessionAdded { session });
|
||||
}
|
||||
|
||||
// #167: say which policy ran and how many sessions it acted on, so a
|
||||
// host that resumes agents unattended says so in its own log. The
|
||||
// match is exhaustive on purpose: a fourth policy must decide what it
|
||||
// does here rather than inherit whichever arm happened to be last.
|
||||
match self.inner.resume_policy {
|
||||
ResumeOnBoot::Dormant => tracing::info!(
|
||||
policy = ResumeOnBoot::Dormant.as_str(),
|
||||
sessions = resumable.len(),
|
||||
"resume_on_boot: sessions left dormant for the operator"
|
||||
),
|
||||
ResumeOnBoot::Never => tracing::info!(
|
||||
policy = ResumeOnBoot::Never.as_str(),
|
||||
sessions = discarded,
|
||||
"resume_on_boot: pruned the sidecars of sessions with no live pane"
|
||||
),
|
||||
ResumeOnBoot::Auto => {
|
||||
tracing::info!(
|
||||
policy = ResumeOnBoot::Auto.as_str(),
|
||||
sessions = resumable.len(),
|
||||
"resume_on_boot: resuming sessions with no live pane"
|
||||
);
|
||||
let presets = self.inner.resume_presets.clone();
|
||||
for id in resumable {
|
||||
// The same call the client's POST /api/sessions/:id/resume
|
||||
// makes — one resume path, not two.
|
||||
if let Err(e) = self.resume(id, &presets).await {
|
||||
tracing::warn!(error = %e, id = %id, "resume_on_boot=auto: resume failed; session stays dormant");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward keystrokes to a session's pane. A dead or dormant session is
|
||||
|
||||
@@ -357,3 +357,237 @@ async fn post_resume_on_an_unknown_session_is_404() {
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Races (#175)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Real tmux, with the *first* `new_session_piped` held inside the call until
|
||||
/// the test releases it. Later calls pass straight through, so a second resume
|
||||
/// racing the first never blocks on a gate the first one owns.
|
||||
///
|
||||
/// This is what makes the two races below deterministic rather than timing
|
||||
/// dependent: the resume under test is parked in exactly the window the bug
|
||||
/// lives in, and the racing operation runs to completion before it moves.
|
||||
#[derive(Debug)]
|
||||
struct GatedSpawn {
|
||||
inner: maestro_daemon::tmux::Tmux,
|
||||
entered: tokio::sync::mpsc::UnboundedSender<()>,
|
||||
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
|
||||
gated: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
/// The test's end of a [`GatedSpawn`].
|
||||
struct Gate {
|
||||
entered: tokio::sync::mpsc::UnboundedReceiver<()>,
|
||||
release: std::sync::mpsc::SyncSender<()>,
|
||||
}
|
||||
|
||||
impl Gate {
|
||||
/// Wait until the gated spawn is parked mid-resume.
|
||||
async fn wait_entered(&mut self) {
|
||||
tokio::time::timeout(Duration::from_secs(10), self.entered.recv())
|
||||
.await
|
||||
.expect("resume reached the tmux spawn")
|
||||
.expect("gate alive");
|
||||
}
|
||||
|
||||
fn release(&self) {
|
||||
self.release.send(()).expect("gate receiver alive");
|
||||
}
|
||||
}
|
||||
|
||||
fn gated_panes(inner: maestro_daemon::tmux::Tmux) -> (Arc<GatedSpawn>, Gate) {
|
||||
let (entered_tx, entered_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1);
|
||||
(
|
||||
Arc::new(GatedSpawn {
|
||||
inner,
|
||||
entered: entered_tx,
|
||||
release: std::sync::Mutex::new(release_rx),
|
||||
gated: std::sync::atomic::AtomicBool::new(true),
|
||||
}),
|
||||
Gate {
|
||||
entered: entered_rx,
|
||||
release: release_tx,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
impl maestro_daemon::panes::Panes for GatedSpawn {
|
||||
fn new_session_piped(
|
||||
&self,
|
||||
id: Uuid,
|
||||
cwd: &std::path::Path,
|
||||
command: &str,
|
||||
session_env: &std::collections::BTreeMap<String, String>,
|
||||
sink_command: &str,
|
||||
) -> Result<(), maestro_daemon::tmux::TmuxError> {
|
||||
if self.gated.swap(false, std::sync::atomic::Ordering::SeqCst) {
|
||||
let _ = self.entered.send(());
|
||||
let _ = self.release.lock().expect("gate mutex").recv();
|
||||
}
|
||||
self.inner
|
||||
.new_session_piped(id, cwd, command, session_env, sink_command)
|
||||
}
|
||||
|
||||
fn kill_session(&self, id: Uuid) -> Result<(), maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.kill_session(id)
|
||||
}
|
||||
|
||||
fn list_sessions(&self) -> Result<Vec<Uuid>, maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.list_sessions()
|
||||
}
|
||||
|
||||
fn pane_info(
|
||||
&self,
|
||||
id: Uuid,
|
||||
) -> Result<Option<maestro_daemon::tmux::PaneInfo>, maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.pane_info(id)
|
||||
}
|
||||
|
||||
fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.send_keys(id, bytes)
|
||||
}
|
||||
|
||||
fn pipe_pane(&self, id: Uuid, sink: &str) -> Result<(), maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.pipe_pane(id, sink)
|
||||
}
|
||||
|
||||
fn resize_window(
|
||||
&self,
|
||||
id: Uuid,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
) -> Result<(), maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.resize_window(id, cols, rows)
|
||||
}
|
||||
|
||||
fn capture_pane(&self, id: Uuid) -> Result<Option<Vec<u8>>, maestro_daemon::tmux::TmuxError> {
|
||||
self.inner.capture_pane(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// A dormant session whose manager's tmux spawn can be parked by the test.
|
||||
async fn dormant_gated(fx: &DaemonFixture, cwd: &TempDir) -> (SessionManager, Session, Gate) {
|
||||
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");
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let (panes, gate) = gated_panes(fx.tmux.tmux.clone());
|
||||
let manager = SessionManager::new(
|
||||
panes,
|
||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
fx.fifo_dir(),
|
||||
);
|
||||
manager.recover().await.expect("recover");
|
||||
assert_eq!(
|
||||
manager.status(session.id).await,
|
||||
Some(SessionStatus::Dormant)
|
||||
);
|
||||
(manager, session, gate)
|
||||
}
|
||||
|
||||
/// A kill that lands while a resume is inside the tmux spawn must not leave
|
||||
/// the sidecar behind. The kill deletes it; the resume writes it back a moment
|
||||
/// later and then discovers its own record is gone. The sidecar it resurrected
|
||||
/// would otherwise make the next recovery register a ghost dormant session for
|
||||
/// a session that was killed.
|
||||
#[tokio::test]
|
||||
async fn a_kill_during_resume_leaves_no_sidecar_behind() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
|
||||
let id = session.id;
|
||||
|
||||
let resuming = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move { manager.resume(id, &[]).await }
|
||||
});
|
||||
gate.wait_entered().await;
|
||||
|
||||
manager.kill(id, false).await.expect("kill mid-resume");
|
||||
gate.release();
|
||||
|
||||
let err = resuming.await.unwrap().expect_err("record is gone");
|
||||
assert!(matches!(err, SessionError::NotFound(got) if got == id));
|
||||
|
||||
assert!(
|
||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
|
||||
.read(id)
|
||||
.expect("read sidecar")
|
||||
.is_none(),
|
||||
"the killed session's sidecar must not be resurrected by the losing resume"
|
||||
);
|
||||
|
||||
// And so the next recovery registers no ghost.
|
||||
let after = revive(&fx);
|
||||
after.recover().await.expect("recover");
|
||||
assert!(
|
||||
after.snapshot().await.is_empty(),
|
||||
"a killed session must not come back dormant"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two resumes of the same session must not both proceed. The second is
|
||||
/// refused on the state — `already_running` — not on its tmux spawn colliding
|
||||
/// with the first one's.
|
||||
#[tokio::test]
|
||||
async fn a_second_resume_is_refused_while_the_first_is_in_flight() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, session, mut gate) = dormant_gated(&fx, &cwd).await;
|
||||
let id = session.id;
|
||||
|
||||
let resuming = tokio::spawn({
|
||||
let manager = manager.clone();
|
||||
async move { manager.resume(id, &[]).await }
|
||||
});
|
||||
gate.wait_entered().await;
|
||||
|
||||
let err = manager
|
||||
.resume(id, &[])
|
||||
.await
|
||||
.expect_err("a resume is already in flight");
|
||||
assert!(matches!(err, SessionError::AlreadyRunning(got) if got == id));
|
||||
assert_eq!(err.to_error_info().code, "already_running");
|
||||
|
||||
gate.release();
|
||||
let first = resuming.await.unwrap().expect("the first resume wins");
|
||||
assert_eq!(first.status, SessionStatus::Running);
|
||||
assert_eq!(
|
||||
fx.tmux.tmux.list_sessions().expect("list"),
|
||||
vec![id],
|
||||
"exactly one pane for the session"
|
||||
);
|
||||
}
|
||||
|
||||
/// A refused resume must not leave the session marked in-flight: once the
|
||||
/// reason for the refusal is gone, resuming works.
|
||||
#[tokio::test]
|
||||
async fn a_refused_resume_can_be_retried() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let (manager, session) = dormant_session(&fx, &cwd, "sleep 30", "retried").await;
|
||||
|
||||
std::fs::remove_dir_all(cwd.path()).unwrap();
|
||||
let err = manager
|
||||
.resume(session.id, &[])
|
||||
.await
|
||||
.expect_err("cwd is gone");
|
||||
assert_eq!(err.to_error_info().code, "cwd_missing");
|
||||
|
||||
std::fs::create_dir_all(cwd.path()).unwrap();
|
||||
let resumed = manager.resume(session.id, &[]).await.expect("resume");
|
||||
assert_eq!(resumed.status, SessionStatus::Running);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! `[daemon].resume_on_boot` (#167): what recovery does with sessions whose
|
||||
//! tmux pane is gone but whose sidecar survived, against real tmux.
|
||||
//!
|
||||
//! The default is deliberately the timid one. Resuming agents unattended
|
||||
//! seconds after a reboot is a foot-gun, so `auto` is opted into per host and
|
||||
//! `never` throws the sidecars away instead.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{wait_for_async, DaemonFixture};
|
||||
use maestro_config::ResumeOnBoot;
|
||||
use maestro_daemon::SessionManager;
|
||||
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus, SpawnPreset};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// A fresh manager over the same tmux server, sidecars and fifos — what the
|
||||
/// daemon builds after a restart — carrying `policy`.
|
||||
fn revive(fx: &DaemonFixture, policy: ResumeOnBoot, presets: Vec<SpawnPreset>) -> SessionManager {
|
||||
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_resume_policy(policy, presets)
|
||||
}
|
||||
|
||||
/// A preset whose `resume_command` touches a marker file, so a test can see
|
||||
/// that the resumed session ran the resume command rather than the original.
|
||||
fn marker_preset(command: &str, marker_dir: &TempDir) -> Vec<SpawnPreset> {
|
||||
vec![SpawnPreset {
|
||||
id: "agent".into(),
|
||||
label: "Agent".into(),
|
||||
command: command.into(),
|
||||
resume_command: Some(format!(
|
||||
"sh -c 'touch {}/resumed; sleep 30'",
|
||||
marker_dir.path().display()
|
||||
)),
|
||||
}]
|
||||
}
|
||||
|
||||
async fn create(fx: &DaemonFixture, cwd: &TempDir, command: &str) -> Session {
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create")
|
||||
}
|
||||
|
||||
/// The default: a reboot leaves the session listed but not running, waiting
|
||||
/// for the operator.
|
||||
#[tokio::test]
|
||||
async fn dormant_is_the_default_and_leaves_the_session_waiting() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = create(&fx, &cwd, "sleep 30").await;
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx, ResumeOnBoot::Dormant, Vec::new());
|
||||
revived.recover().await.expect("recover");
|
||||
|
||||
assert_eq!(
|
||||
revived.status(session.id).await,
|
||||
Some(SessionStatus::Dormant)
|
||||
);
|
||||
assert!(fx
|
||||
.sidecar_dir()
|
||||
.join(format!("{}.json", session.id))
|
||||
.exists());
|
||||
assert!(
|
||||
fx.tmux.tmux.list_sessions().expect("list").is_empty(),
|
||||
"nothing was respawned"
|
||||
);
|
||||
}
|
||||
|
||||
/// `auto` brings each one back itself, down the same path a client's resume
|
||||
/// takes: same uuid, same sidecar, the preset's `resume_command`.
|
||||
#[tokio::test]
|
||||
async fn auto_resumes_every_dormant_session_during_recovery() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let markers = TempDir::new().unwrap();
|
||||
let session = create(&fx, &cwd, "sleep 30").await;
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx, ResumeOnBoot::Auto, marker_preset("sleep 30", &markers));
|
||||
revived.recover().await.expect("recover");
|
||||
|
||||
assert_eq!(
|
||||
revived.status(session.id).await,
|
||||
Some(SessionStatus::Running),
|
||||
"auto resumed it"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.tmux.tmux.list_sessions().expect("list"),
|
||||
vec![session.id],
|
||||
"back under the same uuid"
|
||||
);
|
||||
let stored = maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir())
|
||||
.read(session.id)
|
||||
.expect("read sidecar")
|
||||
.expect("same sidecar");
|
||||
assert_eq!(stored.created_at, session.created_at);
|
||||
assert_eq!(stored.command, "sleep 30", "the original command is kept");
|
||||
|
||||
// It really ran the preset's resume command, not the spawn command.
|
||||
let marker = markers.path().join("resumed");
|
||||
wait_for_async(Duration::from_secs(5), || {
|
||||
let marker = marker.clone();
|
||||
async move { marker.exists() }
|
||||
})
|
||||
.await;
|
||||
assert!(marker.exists(), "resume_command ran");
|
||||
}
|
||||
|
||||
/// `never` makes a reboot a clean slate: the sidecars go, and the session is
|
||||
/// not listed at all.
|
||||
#[tokio::test]
|
||||
async fn never_prunes_the_sidecars_instead() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let session = create(&fx, &cwd, "sleep 30").await;
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx, ResumeOnBoot::Never, Vec::new());
|
||||
revived.recover().await.expect("recover");
|
||||
|
||||
assert!(!revived.has(session.id).await, "not listed");
|
||||
assert!(
|
||||
!fx.sidecar_dir()
|
||||
.join(format!("{}.json", session.id))
|
||||
.exists(),
|
||||
"sidecar pruned"
|
||||
);
|
||||
assert!(fx.tmux.tmux.list_sessions().expect("list").is_empty());
|
||||
}
|
||||
|
||||
/// The policy only decides what happens to sessions that lost their pane. A
|
||||
/// session tmux still holds is adopted running by the first pass under every
|
||||
/// policy — including `never`, which must not delete a live session's sidecar.
|
||||
#[tokio::test]
|
||||
async fn the_policy_leaves_sessions_tmux_still_holds_alone() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let live = create(&fx, &cwd, "sleep 30").await;
|
||||
|
||||
let revived = revive(&fx, ResumeOnBoot::Never, Vec::new());
|
||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||
|
||||
assert_eq!(revived.status(live.id).await, Some(SessionStatus::Running));
|
||||
assert!(fx.sidecar_dir().join(format!("{}.json", live.id)).exists());
|
||||
}
|
||||
@@ -326,12 +326,14 @@ fn revive_with_boot_id(
|
||||
)
|
||||
}
|
||||
|
||||
/// #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`.
|
||||
/// #174: a boot id can change without the tmux server dying — a restored
|
||||
/// state directory, a VM or container snapshot restore, a truncated
|
||||
/// `boot_id` file. `tmux list-sessions` stays the single source of truth for
|
||||
/// liveness, so a session tmux still holds is adopted `Running` no matter
|
||||
/// what the boot id says. Registering it dormant instead stranded it: resume
|
||||
/// would collide with the tmux name still in use and fail forever.
|
||||
#[tokio::test]
|
||||
async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
||||
async fn recover_adopts_live_session_when_boot_id_changed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let s = fx
|
||||
@@ -349,15 +351,15 @@ async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
||||
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.
|
||||
// tmux session is still alive: the probe runs and finds it.
|
||||
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");
|
||||
assert_eq!(adopted, 1, "tmux still holds the session; adopt it");
|
||||
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].id, s.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
assert_eq!(snap[0].status, SessionStatus::Running);
|
||||
|
||||
// The current boot id is now the stored one, for the next comparison.
|
||||
assert_eq!(
|
||||
@@ -366,6 +368,38 @@ async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
||||
);
|
||||
}
|
||||
|
||||
/// #164, still true after #174: a real reboot takes the tmux server with it,
|
||||
/// so the probe comes back empty and every sidecar is registered dormant.
|
||||
#[tokio::test]
|
||||
async fn recover_registers_dormant_when_boot_id_changed_and_tmux_gone() {
|
||||
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();
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive_with_boot_id(&fx, boot_id_path, Some("current-boot"));
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 0, "no tmux server survived the reboot");
|
||||
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].id, s.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
}
|
||||
|
||||
/// #164: an unchanged boot id is a plain restart — recovery probes tmux as
|
||||
/// before and adopts the still-live session `Running`.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -141,6 +141,16 @@ impl DaemonClient {
|
||||
json(check(resp).await?).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session
|
||||
/// under its own uuid and sidecar. Refused with `409` (`already_running`
|
||||
/// / `cwd_missing`) when there is nothing a respawn could fix.
|
||||
pub async fn resume_session(&self, id: Uuid) -> Result<Session, ClientError> {
|
||||
let resp = self
|
||||
.send(Method::POST, &routes::session_resume(id), &[], None::<&()>)
|
||||
.await?;
|
||||
json(check(resp).await?).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||
/// state. The daemon holds it above its own inference for a TTL, then
|
||||
/// resumes inference. Answers `204 No Content`.
|
||||
|
||||
@@ -51,6 +51,9 @@ pub enum Action {
|
||||
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||
/// its current name.
|
||||
RenameHighlighted,
|
||||
/// Resume the highlighted dormant session (#166, #169): respawns it
|
||||
/// under its own uuid and sidecar via `POST /api/sessions/:id/resume`.
|
||||
ResumeHighlighted,
|
||||
|
||||
// Chrome
|
||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||
@@ -105,6 +108,7 @@ impl Action {
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::ResumeHighlighted => "Resume dormant session".into(),
|
||||
Action::OpenCommandBar => "Go to a session".into(),
|
||||
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||
@@ -134,6 +138,7 @@ impl Action {
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"ResumeHighlighted" => return Ok(Action::ResumeHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||
@@ -192,6 +197,16 @@ mod tests {
|
||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||
}
|
||||
|
||||
/// #169: resume is its own verb, distinct from rename/kill, so a dormant
|
||||
/// session's sidebar card has a keyboard-reachable resume.
|
||||
#[test]
|
||||
fn parses_resume_highlighted() {
|
||||
assert_eq!(
|
||||
Action::parse("ResumeHighlighted").unwrap(),
|
||||
Action::ResumeHighlighted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_labels_are_plain_language() {
|
||||
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
|
||||
|
||||
+163
-20
@@ -34,7 +34,7 @@ use crate::input::{
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::render::{draw, PanePlaceholder, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
@@ -190,6 +190,10 @@ pub enum Effect {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
/// Resume a dormant session (#166, #169): respawns it under its own
|
||||
/// uuid and sidecar. The catalog picks up the resulting `Running` status
|
||||
/// from the daemon's own `SessionUpdated` broadcast.
|
||||
Resume(Uuid),
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -263,6 +267,11 @@ impl App {
|
||||
}
|
||||
_ = self.catalog.changed() => {
|
||||
// Picked up by the frame key's catalog version.
|
||||
let effects = self.poll_resumed_attach();
|
||||
if !effects.is_empty() {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
}
|
||||
self.perform(effects).await;
|
||||
}
|
||||
_ = self.link.changed() => {
|
||||
// The status indicator and the restart overlay both read
|
||||
@@ -414,6 +423,7 @@ impl App {
|
||||
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
||||
Effect::LoadBranches => self.spawn_load_branches(),
|
||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||
Effect::Resume(id) => self.submit_resume(id).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -587,7 +597,10 @@ impl App {
|
||||
}
|
||||
// A previewed session is already attached: committing
|
||||
// to it keeps that stream rather than reopening one.
|
||||
if self.emulators.get(sid).is_none() {
|
||||
// A session that isn't Running has no bridge to
|
||||
// attach to (`docs/PREVIEW.md`) — the pane falls back
|
||||
// to its dormant/exited placeholder instead.
|
||||
if self.emulators.get(sid).is_none() && self.session_running(sid) {
|
||||
effects.push(Effect::Attach(sid));
|
||||
}
|
||||
}
|
||||
@@ -607,6 +620,11 @@ impl App {
|
||||
self.prompt_rename(sid);
|
||||
}
|
||||
}
|
||||
Action::ResumeHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Resume(sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every action can move the highlight or the focus, and the preview is
|
||||
// a function of both (`docs/PREVIEW.md`).
|
||||
@@ -663,13 +681,41 @@ impl App {
|
||||
self.state.preview = Some(sid);
|
||||
// Set outside the input path, so nothing else marks the frame stale.
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
let fresh = self.emulators.get(sid).is_none();
|
||||
if fresh {
|
||||
// A session that isn't Running has no bridge to preview-attach to
|
||||
// (`docs/PREVIEW.md`): the pane falls back to its dormant/exited
|
||||
// placeholder, and the preview budget has nothing of its own to track.
|
||||
let attach = self.emulators.get(sid).is_none() && self.session_running(sid);
|
||||
if attach {
|
||||
self.preview_lru.retain(|s| *s != sid);
|
||||
self.preview_lru.push(sid);
|
||||
}
|
||||
self.trim_preview_budget();
|
||||
if fresh {
|
||||
if attach {
|
||||
vec![Effect::Attach(sid)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `sid` is currently `Running`, per the catalog — the only
|
||||
/// status a live pane attach is allowed for (`docs/PREVIEW.md`).
|
||||
fn session_running(&self, sid: Uuid) -> bool {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.any(|s| s.id == sid && s.status == maestro_protocol::SessionStatus::Running)
|
||||
}
|
||||
|
||||
/// A previewed or committed session gates its attach on `Running`
|
||||
/// (`docs/PREVIEW.md`) and may only become `Running` later — most
|
||||
/// commonly a resume completing. Catch that up on a catalog change
|
||||
/// rather than waiting on an unrelated highlight move.
|
||||
fn poll_resumed_attach(&mut self) -> Vec<Effect> {
|
||||
let Some(sid) = self.state.displayed_session() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.emulators.get(sid).is_none() && self.session_running(sid) {
|
||||
vec![Effect::Attach(sid)]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -1327,6 +1373,15 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a dormant session (#166, #169). The catalog's own
|
||||
/// `SessionUpdated` broadcast carries the `Running` status back; nothing
|
||||
/// is patched locally here.
|
||||
async fn submit_resume(&mut self, sid: Uuid) {
|
||||
if let Err(e) = self.daemon.resume_session(sid).await {
|
||||
self.state.toast(format!("resume failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
|
||||
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
|
||||
/// session counts — the sidebar highlight is never a "focused pane".
|
||||
@@ -1416,21 +1471,32 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||
})
|
||||
});
|
||||
let pane_title = match shown_sid {
|
||||
Some(sid) => {
|
||||
let name = snap
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == sid)
|
||||
.map(crate::sidebar::name_for)
|
||||
.unwrap_or_else(|| "session".into());
|
||||
if previewing {
|
||||
format!("{name} · preview")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
None => "session".into(),
|
||||
let shown_session = shown_sid.and_then(|sid| snap.sessions.iter().find(|s| s.id == sid));
|
||||
let shown_name = shown_session
|
||||
.map(crate::sidebar::name_for)
|
||||
.unwrap_or_else(|| "session".into());
|
||||
let pane_title = if previewing && shown_sid.is_some() {
|
||||
format!("{shown_name} · preview")
|
||||
} else {
|
||||
shown_name.clone()
|
||||
};
|
||||
// docs/PREVIEW.md: a session that isn't Running never gets a live
|
||||
// attach, so an empty pane here means "nothing to attach to", not
|
||||
// merely "not yet connected" — the pane names it and offers resume
|
||||
// instead of a terminal that would die on the daemon's 409.
|
||||
let shown_cwd = shown_session
|
||||
.map(|s| s.cwd.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let pane_placeholder = if pane.is_none() {
|
||||
shown_session
|
||||
.filter(|s| s.status != maestro_protocol::SessionStatus::Running)
|
||||
.map(|s| PanePlaceholder {
|
||||
name: &shown_name,
|
||||
cwd: &shown_cwd,
|
||||
dormant: s.status == maestro_protocol::SessionStatus::Dormant,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let link_status = self.link.status();
|
||||
@@ -1439,6 +1505,7 @@ impl App {
|
||||
home_dir: &self.home_dir,
|
||||
pane_title: &pane_title,
|
||||
pane: pane.as_ref(),
|
||||
pane_placeholder,
|
||||
state: &self.state,
|
||||
draft: self.draft.as_ref(),
|
||||
help: &self.help,
|
||||
@@ -2124,6 +2191,82 @@ mod tests {
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||
}
|
||||
|
||||
/// #169, docs/PREVIEW.md: a session that isn't `Running` never gets a
|
||||
/// live attach — the daemon has nothing to answer a 409 with. Committing
|
||||
/// to it still moves the tab, so the pane can show its placeholder.
|
||||
#[test]
|
||||
fn attach_highlighted_never_attaches_a_dormant_session() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(11);
|
||||
let mut app = make_app();
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
assert_eq!(app.dispatch(Action::AttachHighlighted), Vec::new());
|
||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||
}
|
||||
|
||||
/// #169, docs/PREVIEW.md: moving the highlight across dormant sessions —
|
||||
/// the sharp case after a reboot — must never try a live preview attach.
|
||||
#[test]
|
||||
fn preview_never_attaches_a_dormant_session() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(12);
|
||||
let mut app = make_app();
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
assert_eq!(app.poll_due_preview_at(after_gap()), Vec::new());
|
||||
assert_eq!(app.state.preview, Some(sid));
|
||||
}
|
||||
|
||||
/// #169: a session that becomes `Running` while already shown — a resume
|
||||
/// completing, most commonly — attaches without waiting on an unrelated
|
||||
/// highlight move.
|
||||
#[test]
|
||||
fn a_shown_session_attaches_once_it_turns_running() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(13);
|
||||
let mut app = make_app();
|
||||
let mut dormant = test_session(sid, "/repo");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![dormant.clone()],
|
||||
});
|
||||
app.state.attach_to_focused(sid);
|
||||
assert_eq!(app.poll_resumed_attach(), Vec::new());
|
||||
|
||||
let mut running = dormant;
|
||||
running.status = SessionStatus::Running;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::SessionUpdated { session: running });
|
||||
assert_eq!(app.poll_resumed_attach(), vec![Effect::Attach(sid)]);
|
||||
}
|
||||
|
||||
// -- resume (#166, #169) -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resume_highlighted_asks_for_a_resume() {
|
||||
use maestro_protocol::SessionStatus;
|
||||
let mut app = make_app();
|
||||
let sid = Uuid::from_u128(14);
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(maestro_protocol::CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
|
||||
assert_eq!(
|
||||
app.dispatch(Action::ResumeHighlighted),
|
||||
vec![Effect::Resume(sid)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
@@ -59,6 +59,8 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("N", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
("r", "RenameHighlighted"),
|
||||
// Resume a dormant session (#166, #169).
|
||||
("u", "ResumeHighlighted"),
|
||||
// Focus
|
||||
("ctrl-w h", "FocusSidebar"),
|
||||
// Session scrollback
|
||||
@@ -257,6 +259,10 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "u"),
|
||||
Some(Action::ResumeHighlighted),
|
||||
);
|
||||
}
|
||||
|
||||
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||
|
||||
@@ -72,6 +72,18 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
|
||||
}
|
||||
|
||||
/// What the pane shows in place of a live attach when the session behind it
|
||||
/// isn't `Running` (`docs/PREVIEW.md`): a session that is not `Running` never
|
||||
/// gets a live attach, since the daemon has no bridge to answer one.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PanePlaceholder<'a> {
|
||||
pub name: &'a str,
|
||||
pub cwd: &'a str,
|
||||
/// `true` for a dormant session (offers resume), `false` for a dead one
|
||||
/// (`docs/SIDEBAR.md`'s `exited`/`dormant` fragments never both show).
|
||||
pub dormant: bool,
|
||||
}
|
||||
|
||||
/// Bundle of references the renderer reads from. Built by `App::redraw` each
|
||||
/// frame.
|
||||
#[derive(Debug)]
|
||||
@@ -80,6 +92,9 @@ pub struct RenderInputs<'a> {
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// What to show instead, when `pane` is `None` because the shown session
|
||||
/// isn't `Running` rather than merely not yet connected.
|
||||
pub pane_placeholder: Option<PanePlaceholder<'a>>,
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
@@ -147,6 +162,8 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
} else if let Some(ph) = inputs.pane_placeholder {
|
||||
render_pane_placeholder(f, pane_area, ph);
|
||||
}
|
||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||
// chrome; every remaining cell belongs to the emulator.
|
||||
@@ -239,6 +256,44 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// docs/PREVIEW.md / docs/SIDEBAR.md: what the pane can honestly show for a
|
||||
/// session with no bridge behind it — its metadata and a resume affordance
|
||||
/// for a dormant one, never a terminal that would die on the daemon's 409.
|
||||
fn render_pane_placeholder(f: &mut Frame, area: Rect, ph: PanePlaceholder<'_>) {
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let glyph = if ph.dormant { "◌" } else { "✗" };
|
||||
let status = if ph.dormant {
|
||||
"dormant — resume from the sidebar"
|
||||
} else {
|
||||
"exited"
|
||||
};
|
||||
let mid = area.y + area.height / 2;
|
||||
let lines = [
|
||||
(format!("{glyph} {}", ph.name), surface.fg),
|
||||
(ph.cwd.to_string(), surface.muted),
|
||||
(status.to_string(), surface.muted),
|
||||
];
|
||||
for (i, (text, colour)) in lines.iter().enumerate() {
|
||||
let Some(y) = mid.checked_sub(1).map(|y| y + i as u16) else {
|
||||
continue;
|
||||
};
|
||||
if y >= area.y + area.height {
|
||||
continue;
|
||||
}
|
||||
let x = area.x + (area.width.saturating_sub(text.chars().count() as u16)) / 2;
|
||||
f.render_widget(
|
||||
Paragraph::new(text.as_str()).style(Style::default().bg(surface.bg).fg(*colour)),
|
||||
Rect::new(
|
||||
x,
|
||||
y,
|
||||
text.chars().count().min(area.width as usize) as u16,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
let palette = palette::active();
|
||||
let style = if focused {
|
||||
@@ -435,6 +490,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -532,6 +588,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: Some(&draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -587,6 +644,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -629,6 +687,7 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -656,4 +715,52 @@ mod tests {
|
||||
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
|
||||
assert!(title.contains("session"), "missing title: {title:?}");
|
||||
}
|
||||
|
||||
/// docs/PREVIEW.md: a session that isn't `Running` never gets a live
|
||||
/// attach — the pane names it and offers resume instead of a terminal
|
||||
/// that would die on the daemon's 409.
|
||||
#[test]
|
||||
fn a_dormant_pane_placeholder_names_the_session_and_offers_resume() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let state = AppState::new();
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "worker",
|
||||
pane: None,
|
||||
pane_placeholder: Some(PanePlaceholder {
|
||||
name: "worker",
|
||||
cwd: "/home/n/work",
|
||||
dormant: true,
|
||||
}),
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
|
||||
assert!(text.contains("worker"), "{text}");
|
||||
assert!(text.contains("/home/n/work"), "{text}");
|
||||
assert!(text.contains("dormant"), "{text}");
|
||||
assert!(text.contains("resume"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,8 +184,16 @@ impl Sidebar {
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(key, mut sessions)| {
|
||||
sessions
|
||||
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
||||
// docs/SIDEBAR.md, "Dormant cards": a dormant session takes
|
||||
// the lowest precedence, name notwithstanding, so a reboot
|
||||
// that leaves a tree dormant never buries live work spawned
|
||||
// into it.
|
||||
sessions.sort_by(|a, b| {
|
||||
dormant_rank(a)
|
||||
.cmp(&dormant_rank(b))
|
||||
.then_with(|| name_for(a).cmp(&name_for(b)))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
SidebarGroup {
|
||||
collapsed: collapsed.contains(&key),
|
||||
key,
|
||||
@@ -276,11 +284,28 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(fill.filled());
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
// docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant
|
||||
// session's activity to Idle and there is nothing running to report
|
||||
// otherwise, so the rail carries no colour — the muted role, not the
|
||||
// idle one.
|
||||
let dormant = s.status == SessionStatus::Dormant;
|
||||
let muted_style = base.patch(
|
||||
surface
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||
let rail_style = if dormant {
|
||||
base.fg(surface.muted)
|
||||
} else {
|
||||
base.fg(activity_color(s.activity, fill))
|
||||
};
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
@@ -291,18 +316,19 @@ impl Sidebar {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
Span::styled(
|
||||
format!("{glyph} "),
|
||||
if dormant { muted_style } else { base },
|
||||
),
|
||||
];
|
||||
// #131: a name the daemon generated is provisional — the session's
|
||||
// first prompt may replace it — so it reads muted, like the meta line,
|
||||
// rather than as a name someone chose. See docs/SIDEBAR.md.
|
||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||
base.patch(
|
||||
palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
)
|
||||
// A dormant card (#168, #169) is a muted variant of the whole card —
|
||||
// the same muted foreground `exited` already uses — regardless of
|
||||
// whether the name itself is provisional.
|
||||
let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) {
|
||||
muted_style
|
||||
} else {
|
||||
base
|
||||
};
|
||||
@@ -328,11 +354,18 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||
let muted = palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
// docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the
|
||||
// duration that follows it (rendered in the rail's own colour) is
|
||||
// muted rather than the idle role.
|
||||
let rail_style = if dormant {
|
||||
base.patch(muted)
|
||||
} else {
|
||||
base.patch(Style::default().fg(activity_color(s.activity, fill)))
|
||||
};
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
@@ -458,6 +491,10 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
||||
spans
|
||||
}
|
||||
|
||||
fn dormant_rank(s: &Session) -> u8 {
|
||||
u8::from(s.status == SessionStatus::Dormant)
|
||||
}
|
||||
|
||||
/// `metadata.name` if present, else the command line.
|
||||
pub(crate) fn name_for(s: &Session) -> String {
|
||||
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||
@@ -1108,6 +1145,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts
|
||||
/// after every live one in its folder, name notwithstanding.
|
||||
#[test]
|
||||
fn dormant_sessions_sort_after_live_ones_in_their_folder() {
|
||||
let mut dormant = session_named(1, "aaa-dormant");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
let live = session_named(2, "zzz-live");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![dormant, live],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["zzz-live", "aaa-dormant"]);
|
||||
}
|
||||
|
||||
/// Dormant sessions still sort by name against each other.
|
||||
#[test]
|
||||
fn dormant_sessions_still_sort_by_name_against_each_other() {
|
||||
let mut b = session_named(1, "b");
|
||||
b.status = SessionStatus::Dormant;
|
||||
let mut a = session_named(2, "a");
|
||||
a.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![b, a],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of
|
||||
/// the normal card — the same muted foreground `exited` already uses —
|
||||
/// covering the glyph and the name, not only the meta line.
|
||||
#[test]
|
||||
fn dormant_card_renders_its_name_and_glyph_muted() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let colour_of = |needle: char| {
|
||||
(0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("must be drawn")
|
||||
};
|
||||
assert_eq!(colour_of('c'), surface.muted, "the dormant name");
|
||||
assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph");
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle
|
||||
/// and there is nothing running to report, so the rail is driven by the
|
||||
/// muted role directly rather than `activity_color`.
|
||||
#[test]
|
||||
fn dormant_card_rail_carries_no_colour() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
s.activity = ActivityState::Idle;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let rail_colour = (0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == RAIL)
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("rail must be drawn");
|
||||
assert_eq!(rail_colour, surface.muted);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
|
||||
@@ -243,6 +243,26 @@ Pick `/tmp` if the daemon must outlive your login session and you can't run
|
||||
tmux sockets with it). The env var `MAESTRO_DAEMON_TMUX_TMPDIR` takes
|
||||
precedence over the file.
|
||||
|
||||
## What happens to sessions after a reboot
|
||||
|
||||
A host reboot kills the tmux server, so every session the daemon knew is dead
|
||||
while its record on disk survives. What recovery does with those is
|
||||
`[daemon].resume_on_boot`:
|
||||
|
||||
```toml
|
||||
[daemon]
|
||||
resume_on_boot = "dormant" # "dormant" (default) | "auto" | "never"
|
||||
```
|
||||
|
||||
- `dormant` — the sessions are listed but not running, and you resume the ones
|
||||
you want from the web UI or the TUI.
|
||||
- `auto` — the daemon resumes them all itself during startup. Only pick this
|
||||
on a host where restarting every agent unattended is safe: they come back on
|
||||
the flags they were spawned with, with nobody watching.
|
||||
- `never` — the records are deleted and a reboot is a clean slate.
|
||||
|
||||
See `docs/RESTART.md` for the full contract.
|
||||
|
||||
## What the systemd unit looks like
|
||||
|
||||
The wizard writes a *user* unit (`systemctl --user`), since `maestro daemon`
|
||||
|
||||
+46
-2
@@ -75,7 +75,13 @@ A daemon restart leaves tmux alone, so recovery re-adopts every
|
||||
beyond the reconnect above.
|
||||
|
||||
A **host reboot** is different: it kills the tmux server, so there is nothing
|
||||
to adopt while every session's sidecar is still on disk. Recovery makes a
|
||||
to adopt while every session's sidecar is still on disk. Recovery always asks
|
||||
tmux — `tmux list-sessions` is the single source of truth for liveness, and the
|
||||
kernel's `boot_id` only tells a reboot from a restart in the log (#164, #174).
|
||||
A boot id changes without the tmux server dying whenever the state directory is
|
||||
restored, a VM or container snapshot is rolled back, or the `boot_id` file is
|
||||
truncated; a session tmux still holds is adopted `Running` in every one of
|
||||
those cases. Recovery makes a
|
||||
second pass over those sidecars and registers each as a `Dormant` session
|
||||
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
||||
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
||||
@@ -92,10 +98,48 @@ conversation id is respawned on the command it was created with. `Session::comma
|
||||
always keeps that original command, so a second resume resolves the same preset.
|
||||
Resume is refused with `409` for a session that is already running
|
||||
(`already_running`) and for one whose `cwd` has disappeared (`cwd_missing`) —
|
||||
neither is something a respawn could fix. The other thing to do with a dormant
|
||||
neither is something a respawn could fix. A session with a resume already in
|
||||
flight is refused as `already_running` too (#175): the respawn happens with
|
||||
the session map unlocked, so the record is marked in-flight before it starts
|
||||
and the second request is answered on that state rather than on its tmux spawn
|
||||
colliding with the first one's. A resume that loses a race with a kill takes
|
||||
its own sidecar back off disk, so the killed session does not return as a
|
||||
dormant ghost on the next recovery. The other thing to do with a dormant
|
||||
session is dismiss it, which removes the sidecar so the next recovery does not
|
||||
bring it back.
|
||||
|
||||
## What recovery does with them: `resume_on_boot`
|
||||
|
||||
Whether a recovered-but-dead session is left dormant at all is the operator's
|
||||
call, per host, via `[daemon].resume_on_boot` (#167):
|
||||
|
||||
| value | what recovery does |
|
||||
| ----------- | --------------------------------------------------------------------- |
|
||||
| `"dormant"` | **Default.** Register each one dormant and wait for the operator. |
|
||||
| `"auto"` | Resume each one during recovery, itself. |
|
||||
| `"never"` | Prune the sidecars instead — a reboot is a clean slate. |
|
||||
|
||||
`dormant` is the default because resuming agents unattended is a foot-gun:
|
||||
they come back on whatever flags they were spawned with, seconds after a
|
||||
reboot, with nobody watching. `auto` exists for hosts where that is known to
|
||||
be safe, and it is opted into rather than inherited.
|
||||
|
||||
`auto` calls the same `resume` the client's `POST /api/sessions/:id/resume`
|
||||
does — same uuid, same sidecar, the preset's `resume_command` — so there is
|
||||
one resume path and not two. A session that fails to resume is logged and
|
||||
stays dormant; the rest still come back.
|
||||
|
||||
`never` prunes the same way #163 does for a missing `cwd`: the sidecar is
|
||||
deleted and nothing is registered, so the session is not listed and the next
|
||||
recovery does not find it. Whatever worktree it owned is left on disk.
|
||||
|
||||
The policy only decides the fate of sessions with no live pane. A session
|
||||
tmux still holds is adopted `Running` by the first pass under every policy,
|
||||
including `never`.
|
||||
|
||||
Recovery logs the policy that ran and the number of sessions it acted on, so
|
||||
a host that resumes agents unattended says so in its own journal.
|
||||
|
||||
Attaching to a session that has no bridge answers `409 not_attachable`, not
|
||||
`404 unknown_session`: the session exists, and a client that cannot tell "gone"
|
||||
from "not running" cannot offer the resume.
|
||||
|
||||
@@ -358,6 +358,11 @@ The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
in both help overlays.
|
||||
|
||||
The TUI additionally binds `u` to resume the highlighted dormant session
|
||||
(#166, #169). The web client has no keystroke for it — its own resume
|
||||
affordance is the card's ↻ button and the context menu's **Resume** entry, per
|
||||
"Dormant cards" above — so this one has no web-side pair.
|
||||
|
||||
## Killing a session
|
||||
|
||||
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||
|
||||
Reference in New Issue
Block a user