Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
915e408c16 | ||
|
|
577434dc43 | ||
|
|
cc1e7b5f98 | ||
|
|
c29d6f4339 | ||
|
|
938dd62648 | ||
|
|
8269bcabcc |
@@ -129,80 +129,6 @@ 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).
|
||||
@@ -849,27 +775,4 @@ 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,13 +2,10 @@
|
||||
//!
|
||||
//! `/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 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.
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -87,10 +87,7 @@ 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,10 +97,6 @@ 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 {
|
||||
@@ -153,8 +149,6 @@ 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())
|
||||
@@ -215,7 +209,6 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -296,34 +289,6 @@ 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,7 +12,6 @@ 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,
|
||||
@@ -87,11 +86,6 @@ 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.
|
||||
@@ -201,11 +195,6 @@ 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
|
||||
@@ -264,8 +253,6 @@ impl SessionManager {
|
||||
viewports: Mutex::new(Viewports::new()),
|
||||
next_viewport: AtomicU64::new(0),
|
||||
boot_id: None,
|
||||
resume_policy: ResumeOnBoot::default(),
|
||||
resume_presets: Vec::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -310,26 +297,6 @@ 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
|
||||
@@ -600,7 +567,6 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -652,23 +618,15 @@ 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 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 {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get(&id).ok_or(SessionError::NotFound(id))?;
|
||||
if rec.session.status == SessionStatus::Running {
|
||||
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);
|
||||
@@ -700,7 +658,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -720,7 +677,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -728,17 +684,9 @@ 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 — 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);
|
||||
// leaving one behind with no record of it.
|
||||
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();
|
||||
@@ -748,7 +696,6 @@ 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(),
|
||||
@@ -756,16 +703,6 @@ 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>) {
|
||||
@@ -882,20 +819,20 @@ impl SessionManager {
|
||||
/// that no longer exists are still adopted — the spec does not require
|
||||
/// us to repair them.
|
||||
///
|
||||
/// #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.
|
||||
/// #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.
|
||||
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 = {
|
||||
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 tmux = self.inner.tmux.clone();
|
||||
tmux_blocking(move || tmux.list_sessions()).await?
|
||||
};
|
||||
@@ -957,7 +894,6 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -976,9 +912,7 @@ 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". 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.
|
||||
/// "no reboot", which is today's behaviour: fall back to probing tmux.
|
||||
async fn check_boot_id(&self) -> bool {
|
||||
let Some(cfg) = &self.inner.boot_id else {
|
||||
return false;
|
||||
@@ -1025,8 +959,7 @@ 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. It never decides membership — everything here got
|
||||
/// here by tmux not holding it (#174).
|
||||
/// silent either way.
|
||||
async fn register_dormant(&self, rebooted: bool) {
|
||||
let stored = match self.inner.sidecar.load_all() {
|
||||
Ok(stored) => stored,
|
||||
@@ -1037,7 +970,6 @@ 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 {
|
||||
@@ -1052,16 +984,6 @@ 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;
|
||||
@@ -1084,7 +1006,6 @@ impl SessionManager {
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
resuming: false,
|
||||
},
|
||||
);
|
||||
registered.push(session);
|
||||
@@ -1104,45 +1025,12 @@ 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
|
||||
@@ -1305,12 +1193,10 @@ impl SessionManager {
|
||||
/// and stays silent on the event stream at steady state.
|
||||
///
|
||||
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
|
||||
/// by the caller from the hook payload's `session_id` field. The
|
||||
/// sidecar is rewritten only when the reported id differs from the
|
||||
/// stored one — a repeated report of the same id is pure churn from the
|
||||
/// hook's `PreToolUse` cadence and writes nothing, but a resume (#176)
|
||||
/// issues a new conversation id and must replace the stale one, or
|
||||
/// every later resume reattaches the pre-resume conversation.
|
||||
/// by the caller from the hook payload's `session_id` field. It is
|
||||
/// written once — the first report that carries one wins — and the
|
||||
/// sidecar is only rewritten on that transition, never on every report,
|
||||
/// since an agent's session id never changes mid-session.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
@@ -1332,16 +1218,16 @@ impl SessionManager {
|
||||
state,
|
||||
self.inner.report_ttl,
|
||||
));
|
||||
let id_changed =
|
||||
agent_session_id.is_some() && rec.session.agent_session_id != agent_session_id;
|
||||
if id_changed {
|
||||
let newly_identified =
|
||||
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
|
||||
if newly_identified {
|
||||
rec.session.agent_session_id = agent_session_id;
|
||||
self.inner
|
||||
.sidecar
|
||||
.write(&rec.session)
|
||||
.map_err(SessionError::Sidecar)?;
|
||||
}
|
||||
(changed || id_changed).then(|| rec.session.clone())
|
||||
(changed || newly_identified).then(|| rec.session.clone())
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = self
|
||||
|
||||
@@ -187,30 +187,10 @@ async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
/// #161: repeated reports of the same id are pure churn from the hook's
|
||||
/// `PreToolUse` cadence — the sidecar must not be rewritten for them.
|
||||
/// #161: a session id does not change mid-session, so once set it is never
|
||||
/// overwritten by a later report — even one that names a different id.
|
||||
#[tokio::test]
|
||||
async fn a_repeated_report_of_the_same_session_id_writes_nothing() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
}
|
||||
|
||||
/// #176: a resume issues a new conversation id. The stored id must move to
|
||||
/// it, or every later resume reattaches the stale pre-resume conversation.
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_replaced_when_it_differs() {
|
||||
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
@@ -224,14 +204,7 @@ async fn a_reported_session_id_is_replaced_when_it_differs() {
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-second"));
|
||||
|
||||
let stored = fx
|
||||
.sidecar
|
||||
.read(id)
|
||||
.expect("sidecar read")
|
||||
.expect("sidecar present");
|
||||
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-second"));
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -357,237 +357,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
//! `[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,14 +326,12 @@ fn revive_with_boot_id(
|
||||
)
|
||||
}
|
||||
|
||||
/// #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.
|
||||
/// #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_adopts_live_session_when_boot_id_changed() {
|
||||
async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let s = fx
|
||||
@@ -351,15 +349,15 @@ async fn recover_adopts_live_session_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: the probe runs and finds it.
|
||||
// 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, 1, "tmux still holds the session; adopt it");
|
||||
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::Running);
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
|
||||
// The current boot id is now the stored one, for the next comparison.
|
||||
assert_eq!(
|
||||
@@ -368,38 +366,6 @@ async fn recover_adopts_live_session_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]
|
||||
|
||||
@@ -243,26 +243,6 @@ 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`
|
||||
|
||||
@@ -28,6 +28,12 @@ A preview is a view, not a state. Nothing about it is durable:
|
||||
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
||||
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
||||
|
||||
A session that is not `Running` — a dormant one, most often — never gets a
|
||||
live attach here or anywhere else a pane is filled: the daemon has nothing to
|
||||
answer a `409 not_attachable` and the socket would just die (WS close 1006).
|
||||
Its pane shows the same metadata-and-resume view `docs/SIDEBAR.md` describes
|
||||
for its card, honest about the fact that there is nothing to preview yet.
|
||||
|
||||
The throwaway tab never counts as an open session: the tab bar does not appear
|
||||
because of it, the mobile shell does not switch away from the session list for
|
||||
it, and the sidebar does not mark the session as open.
|
||||
|
||||
+2
-46
@@ -75,13 +75,7 @@ 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 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
|
||||
to adopt while every session's sidecar is still on disk. 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
|
||||
@@ -98,48 +92,10 @@ 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. 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
|
||||
neither is something a respawn could fix. 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.
|
||||
|
||||
@@ -113,6 +113,33 @@ pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
||||
observed ending — so the two fragments never both show. The TUI marks the same
|
||||
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||
|
||||
### Dormant cards (#168)
|
||||
|
||||
A dormant card is a muted variant of the normal card (the web client's
|
||||
`is-dormant` class, the same muted foreground `exited` already uses): name,
|
||||
cwd and last-known age render as usual, but the activity rail carries no
|
||||
colour, since recovery resets a dormant session's activity to `Idle` and
|
||||
there is nothing running to report otherwise.
|
||||
|
||||
The row's right-hand actions swap **spawn similar** for **resume** (↻) — there
|
||||
is no live state to clone from — and call `POST /api/sessions/:id/resume`
|
||||
(#166). **Kill** stays: it is how a dormant session is dismissed, since the
|
||||
daemon already treats a kill of a dormant session as removing its sidecar
|
||||
rather than anything it needs to be running to do.
|
||||
|
||||
A dormant session takes the **lowest precedence in the ordering**: within a
|
||||
folder, every live (`Running` or `Dead`) session sorts before every dormant
|
||||
one, name notwithstanding, and dormant sessions sort among themselves by name.
|
||||
So a reboot that leaves a whole tree dormant never buries the live work a
|
||||
session spawns into it.
|
||||
|
||||
Selecting a dormant session — by click, by keyboard, or by preview
|
||||
(`docs/PREVIEW.md`) — never opens a live attach: the content pane shows the
|
||||
same metadata and resume affordance the card does, rather than a terminal
|
||||
that would die on the daemon's `409 not_attachable` (WS close 1006). Resuming
|
||||
turns the session `Running`; the pane and the card pick that up on their own
|
||||
next update, no navigation required.
|
||||
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||
while something would block an unforced kill, and reads:
|
||||
|
||||
@@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Respawn a dormant session under its own uuid and sidecar (#166/#168).
|
||||
* Refused with `409` (`already_running` / `cwd_missing`) when there is
|
||||
* nothing a respawn could fix. */
|
||||
export async function resumeSession(id: Uuid): Promise<Session> {
|
||||
return jsonOrError(
|
||||
await fetch(routes.sessionResume(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
|
||||
+22
-3
@@ -65,9 +65,17 @@ export interface AttachOptions {
|
||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
/** Invoked when the handshake itself never completed — no retry was
|
||||
* attempted (see `shouldReconnect`'s `everOpened`). Unlike `onClosed`, this
|
||||
* is not "the session is gone": it is at least as likely a dormant
|
||||
* session's `409 not_attachable` caught mid-status-update (#168 review).
|
||||
* The shell re-renders rather than tearing the pane's leaf out, so a
|
||||
* dormant session's resume affordance can take over in place. */
|
||||
onNeverAttached?: (sessionId: Uuid) => void;
|
||||
/** Asked on every close: should the socket be retried? The shell answers
|
||||
* from the catalog — see `shouldRetryClose`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
* from the catalog — see `shouldRetryClose`. `everOpened` is false when
|
||||
* this attempt never reached `open`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number, everOpened: boolean) => boolean;
|
||||
}
|
||||
|
||||
/** What the header shows about the session (name / command). Optional — falls
|
||||
@@ -379,6 +387,10 @@ export function createAttachConnection(
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
// #168 review: a fresh attempt that never reaches `open` is a rejected
|
||||
// handshake (dormant/unknown session), not a dropped stream — see
|
||||
// `shouldRetryClose`'s `everOpened` param.
|
||||
let everOpened = false;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -402,6 +414,7 @@ export function createAttachConnection(
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
everOpened = true;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setState("connected", "open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
@@ -444,7 +457,7 @@ export function createAttachConnection(
|
||||
// close handle their own pane teardown.
|
||||
if (disposed) return;
|
||||
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code, everOpened)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
@@ -455,6 +468,12 @@ export function createAttachConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!everOpened) {
|
||||
setState("not attached", "closed");
|
||||
opts.onNeverAttached?.(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(`closed (${ev.code})`, "closed");
|
||||
opts.onClosed?.(sessionId);
|
||||
};
|
||||
|
||||
@@ -427,6 +427,44 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.textContent).not.toContain("exited");
|
||||
});
|
||||
|
||||
// #168: a dormant card is a muted variant with a resume action in place of
|
||||
// "spawn similar", since there is nothing running to clone from live state.
|
||||
it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
expect(row.classList.contains("is-dormant")).toBe(true);
|
||||
expect(row.querySelector(".leaf-clone")).toBeNull();
|
||||
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||
expect(resumeBtn).toBeTruthy();
|
||||
resumeBtn?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`POST /api/sessions/${s.id}/resume`);
|
||||
});
|
||||
|
||||
// #168: a two-way running/dead test made a dormant card lie to screen
|
||||
// readers — the visible flag says dormant, the aria-label said "exited".
|
||||
it("marks a dormant row's status and aria-label distinctly from dead", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000119",
|
||||
status: "Dormant",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
expect(row.dataset.status).toBe("dormant");
|
||||
expect(row.getAttribute("aria-label")).toContain("dormant");
|
||||
expect(row.getAttribute("aria-label")).not.toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
|
||||
+53
-19
@@ -1,5 +1,5 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js";
|
||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { toggleHelp } from "./help.js";
|
||||
@@ -104,6 +104,21 @@ const STATE_WORDS: Record<string, string> = {
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and
|
||||
// the aria-label read the session's own three-way `status` rather than a
|
||||
// two-way running/dead test that folded `Dormant` into "dead"/"exited".
|
||||
function statusSlug(status: Session["status"]): string {
|
||||
if (status === "Running") return "running";
|
||||
if (status === "Dormant") return "dormant";
|
||||
return "dead";
|
||||
}
|
||||
|
||||
function statusWord(status: Session["status"]): string {
|
||||
if (status === "Running") return "running";
|
||||
if (status === "Dormant") return "dormant";
|
||||
return "exited";
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||
* nesting depth, carrying the rail with it. */
|
||||
@@ -203,33 +218,46 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
row.dataset.navKey = `session:${s.id}`;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
row.dataset.status = statusSlug(s.status);
|
||||
// docs/SIDEBAR.md: a dormant card is a muted variant — nothing running
|
||||
// behind it, so it earns no activity colour or spawn-similar affordance.
|
||||
const dormant = s.status === "Dormant";
|
||||
row.classList.toggle("is-dormant", dormant);
|
||||
const name = sessionName(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
|
||||
const cloneOrResumeAction = dormant
|
||||
? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
|
||||
: `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
${activityRailMarkup(slug, stateWord)}
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
${cloneOrResumeAction}
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
|
||||
similarBtn?.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||
resumeBtn?.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
resumeBtn.disabled = true;
|
||||
void resumeSession(cur.id).catch((e: Error) => {
|
||||
resumeBtn.disabled = false;
|
||||
alert(`Failed to resume: ${e.message}`);
|
||||
});
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -244,11 +272,19 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
cur.status === "Dormant"
|
||||
? {
|
||||
label: "Resume",
|
||||
onSelect: () =>
|
||||
void resumeSession(cur.id).catch((e: Error) =>
|
||||
alert(`Failed to resume: ${e.message}`),
|
||||
),
|
||||
}
|
||||
: {
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
@@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
row.dataset.status = statusSlug(next.status);
|
||||
row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
|
||||
@@ -25,8 +25,13 @@ export interface PoolOptions {
|
||||
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
||||
* not via an intentional close/eviction. The embedder closes the pane. */
|
||||
onClosed?: (id: Uuid) => void;
|
||||
/** Fired when a connection's handshake never completed and so was never
|
||||
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
|
||||
* embedder should not assume the session is gone — a dormant session's
|
||||
* pane wants its resume affordance, not teardown. */
|
||||
onNeverAttached?: (id: Uuid) => void;
|
||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +90,9 @@ export class ConnectionPool {
|
||||
{
|
||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
|
||||
shouldReconnect: (sid, code, everOpened) =>
|
||||
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
|
||||
},
|
||||
info,
|
||||
);
|
||||
|
||||
+29
-1
@@ -19,6 +19,7 @@ import {
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
projects as fetchProjects,
|
||||
resumeSession,
|
||||
} from "./api.js";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
@@ -172,17 +173,42 @@ const pool = new ConnectionPool({
|
||||
// pane is never left lying around.
|
||||
onRequestClose: (id) => closePane(id),
|
||||
onClosed: (id) => closePane(id),
|
||||
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
||||
// #168 review: a handshake that never opened (a dormant session's `409
|
||||
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
|
||||
// connection and re-render so the leaf gets a fresh attempt if the catalog
|
||||
// still says Running, or the resume affordance if it doesn't.
|
||||
onNeverAttached: (id) => {
|
||||
pool.close(id);
|
||||
mv?.render();
|
||||
},
|
||||
shouldReconnect: (id, code, everOpened) =>
|
||||
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
|
||||
});
|
||||
|
||||
// Session death used to reach the UI on two uncoordinated channels — the row
|
||||
// from the events socket, the pane from `ws.onclose` — so either could happen
|
||||
// without the other. The catalog is the authority: a pane for a session that is
|
||||
// no longer listed gets closed here.
|
||||
//
|
||||
// A pane can also be showing the dormant placeholder (or, briefly, a dead
|
||||
// socket) rather than a live attach (#168): when an *open* session's
|
||||
// Running-ness flips — most commonly a resume completing — the pane and the
|
||||
// placeholder need to swap without waiting on an unrelated layout change.
|
||||
let lastOpenRunning = new Map<string, boolean>();
|
||||
store.subscribe((state) => {
|
||||
for (const id of mv?.openSessions() ?? []) {
|
||||
if (!state.sessions.has(id)) closePane(id);
|
||||
}
|
||||
const open = mv?.openSessions() ?? new Set<string>();
|
||||
const nextRunning = new Map<string, boolean>();
|
||||
let runningChanged = false;
|
||||
for (const id of open) {
|
||||
const running = state.sessions.get(id)?.status === "Running";
|
||||
nextRunning.set(id, running);
|
||||
if (lastOpenRunning.get(id) !== running) runningChanged = true;
|
||||
}
|
||||
lastOpenRunning = nextRunning;
|
||||
if (runningChanged) mv?.render();
|
||||
});
|
||||
|
||||
function closePane(id: string): void {
|
||||
@@ -275,6 +301,8 @@ mv = createMultiView({
|
||||
// catalog (no sessions) to attach.
|
||||
onOpenSessionsChange: updateShellRoute,
|
||||
onLayoutChange: scheduleSave,
|
||||
sessionFor: (id) => store.snapshot().sessions.get(id),
|
||||
onResume: (id) => resumeSession(id),
|
||||
});
|
||||
let contentShowsMv = false;
|
||||
/** Put the multi-view in the content pane, without rendering it. A preview
|
||||
|
||||
@@ -45,7 +45,7 @@ function fakeSidebar(): SidebarHandle {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMv(layout: Layout): MultiView {
|
||||
function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMultiView>[0]> = {}): MultiView {
|
||||
return createMultiView({
|
||||
pool: fakePool(),
|
||||
layout,
|
||||
@@ -53,6 +53,7 @@ function makeMv(layout: Layout): MultiView {
|
||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +88,102 @@ describe("multiview empty panes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #168 review: highlighting a dormant session used to open a pane and attempt
|
||||
// a live attach, which the daemon answers with 404/409 and the socket dies
|
||||
// with WS 1006. A dormant leaf must never reach the pool at all — it gets the
|
||||
// resume affordance instead.
|
||||
describe("multiview dormant gating (#168)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function dormantSession(id: string) {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n/work",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "worker" },
|
||||
status: "Dormant" as const,
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
it("never hands a dormant session's leaf to the pool, and shows a resume affordance", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||
const setVisible = vi.fn();
|
||||
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||
const mv = createMultiView({
|
||||
pool,
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
expect(setVisible).toHaveBeenCalledWith([], expect.anything());
|
||||
const pane = mv.el.querySelector('[data-leaf-id]') as HTMLElement;
|
||||
expect(pane.querySelector(".mv-pane-dormant")).toBeTruthy();
|
||||
expect(pane.textContent).toContain("worker");
|
||||
expect(pane.textContent).toContain("/home/n/work");
|
||||
expect(pane.querySelector(".mv-dormant-resume")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still attaches a Running session's leaf normally", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "r1", "row", true);
|
||||
const setVisible = vi.fn();
|
||||
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||
const mv = createMultiView({
|
||||
pool,
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
sessionFor: (id) => ({ ...dormantSession(id), status: "Running" }),
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
expect(setVisible).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: "r1" })],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking resume calls back with the session id", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||
const onResume = vi.fn().mockResolvedValue(undefined);
|
||||
const mv = makeMv(layout, {
|
||||
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||
onResume,
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
mv.el.querySelector<HTMLButtonElement>(".mv-dormant-resume")?.click();
|
||||
expect(onResume).toHaveBeenCalledWith("d1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
|
||||
+61
-2
@@ -6,6 +6,10 @@
|
||||
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import { activityDurationLabel, activityHeldMs } from "./activity.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { sessionName } from "./types.js";
|
||||
import type { Session } from "./types.js";
|
||||
import {
|
||||
activeTab,
|
||||
addTab,
|
||||
@@ -34,6 +38,14 @@ export interface MultiViewOptions {
|
||||
onDisconnect: (sessionId: string) => void;
|
||||
/** Welcome content for an empty tab. */
|
||||
renderWelcome: () => HTMLElement;
|
||||
/** The session behind a leaf, when known — used to gate a live attach on
|
||||
* `status === "Running"` (#168 review) and to fill in the dormant pane's
|
||||
* resume affordance. A leaf whose session this returns `undefined` for
|
||||
* (unknown to the caller) attaches as before, so callers that don't wire
|
||||
* this up see no change in behaviour. */
|
||||
sessionFor?: (sessionId: string) => Session | undefined;
|
||||
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
|
||||
onResume?: (sessionId: string) => Promise<unknown>;
|
||||
/** Called when a spawn fills a pane with a new session, so the shell can
|
||||
* re-evaluate its route (sidebar vs attach layout). */
|
||||
onOpenSessionsChange?: () => void;
|
||||
@@ -262,6 +274,44 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
persist();
|
||||
}
|
||||
|
||||
// ---- dormant panes (#168 review) ----
|
||||
|
||||
/** The session behind `sessionId` when it is known and not `Running` —
|
||||
* what gates a leaf away from a live attach and towards the resume
|
||||
* affordance instead. */
|
||||
function dormantSessionFor(sessionId: string): Session | undefined {
|
||||
const s = opts.sessionFor?.(sessionId);
|
||||
return s && s.status !== "Running" ? s : undefined;
|
||||
}
|
||||
|
||||
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
|
||||
* show for a session with no pane behind it — its metadata and a resume
|
||||
* affordance, never a live terminal that would just die with WS 1006. */
|
||||
function renderDormantPane(host: HTMLElement, session: Session): void {
|
||||
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
|
||||
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "mv-pane-dormant";
|
||||
wrap.innerHTML = `
|
||||
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
|
||||
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
|
||||
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
|
||||
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
|
||||
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
|
||||
`;
|
||||
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
|
||||
btn.addEventListener("click", () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Resuming…";
|
||||
opts.onResume?.(session.id)?.catch((e: Error) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Resume";
|
||||
alert(`Failed to resume: ${e.message}`);
|
||||
});
|
||||
});
|
||||
host.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
// ---- pane + split DOM ----
|
||||
|
||||
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
|
||||
@@ -378,7 +428,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (node.kind === "leaf") {
|
||||
const pane = buildPane(node);
|
||||
if (node.sessionId) {
|
||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||
const dormant = dormantSessionFor(node.sessionId);
|
||||
if (dormant) {
|
||||
renderDormantPane(pane.host, dormant);
|
||||
} else {
|
||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||
}
|
||||
}
|
||||
return pane.el;
|
||||
}
|
||||
@@ -616,9 +671,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
wrap.appendChild(pane.el);
|
||||
canvas.appendChild(wrap);
|
||||
|
||||
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
|
||||
if (dormant) renderDormantPane(pane.host, dormant);
|
||||
// Only the focused leaf's session is live on mobile; the rest park.
|
||||
pool.setVisible(
|
||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||
focused.sessionId && !dormant
|
||||
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
|
||||
: [],
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
|
||||
@@ -47,6 +47,13 @@ describe("shouldRetryClose", () => {
|
||||
it("never retries a clean close", () => {
|
||||
expect(shouldRetryClose("a", 1000, running)).toBe(false);
|
||||
});
|
||||
|
||||
// #168 review: a handshake that never opened is a rejection (e.g. a
|
||||
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
|
||||
// loops forever if the catalog status hasn't caught up yet.
|
||||
it("never retries a socket that never opened, even if the catalog still says running", () => {
|
||||
expect(shouldRetryClose("a", 1006, running, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backoff bounds", () => {
|
||||
|
||||
@@ -26,7 +26,15 @@ export function shouldRetryClose(
|
||||
sessionId: Uuid,
|
||||
code: number,
|
||||
sessions: ReadonlyMap<Uuid, Session>,
|
||||
/** Did this socket ever reach `open`? A handshake that never completed is
|
||||
* the daemon rejecting the attach (dormant/unknown) rather than a dropped
|
||||
* stream — retrying it spins in place until the catalog updates, which for
|
||||
* a dormant session it never will (#168 review). Defaults to `true` so
|
||||
* existing callers that only track drops of an established link are
|
||||
* unaffected. */
|
||||
everOpened = true,
|
||||
): boolean {
|
||||
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
|
||||
if (!everOpened) return false;
|
||||
return sessions.get(sessionId)?.status === "Running";
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string {
|
||||
return `${session(id)}/focus`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session
|
||||
* under its own uuid and sidecar. */
|
||||
export function sessionResume(id: Uuid): string {
|
||||
return `${session(id)}/resume`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
+45
-4
@@ -1210,12 +1210,16 @@ textarea {
|
||||
}
|
||||
|
||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||
never the running tint's absence alone doing the work. */
|
||||
.tree-leaf[data-status="dead"] {
|
||||
never the running tint's absence alone doing the work. A dormant session
|
||||
(#168, docs/SIDEBAR.md) reads the same muted way — it is known, not gone,
|
||||
but there is nothing running behind it either. */
|
||||
.tree-leaf[data-status="dead"],
|
||||
.tree-leaf.is-dormant {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tree-leaf[data-status="dead"] .leaf-name {
|
||||
.tree-leaf[data-status="dead"] .leaf-name,
|
||||
.tree-leaf.is-dormant .leaf-name {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -1754,7 +1758,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
}
|
||||
|
||||
.leaf-clone:hover {
|
||||
.leaf-clone:hover,
|
||||
.leaf-resume:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
@@ -3265,6 +3270,42 @@ input.leaf-rename:focus {
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
/* #168: what a pane shows for a dormant session — its metadata and a resume
|
||||
action, in place of the live terminal there is nothing to attach to. */
|
||||
.mv-pane-dormant {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mv-dormant-glyph {
|
||||
font-size: 28px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.mv-dormant-name {
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mv-dormant-cwd {
|
||||
font-size: var(--fs-12);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mv-dormant-detail {
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
.mv-dormant-resume {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
|
||||
.mv-pane-tools {
|
||||
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
||||
import { buildTree, compareNodes, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, cwd: string, command = "bash"): Session {
|
||||
@@ -67,6 +67,42 @@ describe("buildTree", () => {
|
||||
expect(root.children[0].kind).toBe("folder");
|
||||
expect(root.children[1].kind).toBe("leaf");
|
||||
});
|
||||
|
||||
// #168, docs/SIDEBAR.md: a dormant session takes the lowest precedence in
|
||||
// the ordering, so live work always sorts above it, even a name that would
|
||||
// otherwise sort first.
|
||||
it("sorts a dormant session after live ones in the same folder, name notwithstanding", () => {
|
||||
const dormant = { ...session("aaa-dormant", "/home/n/work"), status: "Dormant" } as Session;
|
||||
const live = session("zzz-live", "/home/n/work");
|
||||
const root = buildTree([dormant, live], "/home/n") as TreeFolder;
|
||||
const folder = root.children[0] as TreeFolder;
|
||||
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||
expect(ids).toEqual(["zzz-live", "aaa-dormant"]);
|
||||
});
|
||||
|
||||
it("still sorts dormant sessions by name against each other", () => {
|
||||
const a = {
|
||||
...session("a", "/home/n/work"),
|
||||
status: "Dormant",
|
||||
metadata: { name: "a" },
|
||||
} as Session;
|
||||
const b = {
|
||||
...session("b", "/home/n/work"),
|
||||
status: "Dormant",
|
||||
metadata: { name: "b" },
|
||||
} as Session;
|
||||
const root = buildTree([b, a], "/home/n") as TreeFolder;
|
||||
const folder = root.children[0] as TreeFolder;
|
||||
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("exposes the leaf/name precedence directly via compareNodes", () => {
|
||||
const dormant: TreeNode = { kind: "leaf", session: { ...session("a", "/x"), status: "Dormant" } };
|
||||
const live: TreeNode = { kind: "leaf", session: session("b", "/x") };
|
||||
expect(compareNodes(dormant, live)).toBeGreaterThan(0);
|
||||
expect(compareNodes(live, dormant)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLinear", () => {
|
||||
|
||||
@@ -18,8 +18,16 @@ export interface TreeLeaf {
|
||||
|
||||
export type TreeNode = TreeFolder | TreeLeaf;
|
||||
|
||||
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
|
||||
* ordering, so live work always sorts above it regardless of name. */
|
||||
function dormantRank(node: TreeNode): number {
|
||||
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
|
||||
}
|
||||
|
||||
export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||
const rank = dormantRank(a) - dormantRank(b);
|
||||
if (rank !== 0) return rank;
|
||||
const an = a.kind === "folder" ? a.name : sessionName(a.session);
|
||||
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
|
||||
return an.localeCompare(bn);
|
||||
|
||||
Reference in New Issue
Block a user