From 72644dd564193a3ccdf41ae01cf306510922f450 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Fri, 4 Sep 2026 08:22:47 +0100 Subject: [PATCH] feat(maestro): register dormant sessions on recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host reboot kills the tmux server, so `SessionManager::recover` iterated an empty list and every session vanished from the API while its sidecar sat untouched on disk. A second recovery pass now reads the sidecars tmux had nothing to adopt and registers each as `SessionStatus::Dormant` — a record of what was running, with no pane and no PTY bridge behind it. `SessionStatus` deserializes leniently now, like `ActivityState`: an unknown spelling reads as `Dead` rather than failing the whole `Session`. Refs #162 Co-Authored-By: Claude Opus 5 --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 140 +++++++++++++++--- .../maestro-daemon/tests/session_manager.rs | 134 +++++++++++++++++ crates/maestro-protocol/src/lib.rs | 57 ++++++- crates/maestro-tui/src/command_bar.rs | 23 ++- crates/maestro-tui/src/sidebar.rs | 39 +++++ docs/COMMAND-BAR.md | 6 +- docs/RESTART.md | 20 +++ docs/SIDEBAR.md | 14 +- web/src/catalog.test.ts | 13 ++ web/src/catalog.ts | 4 + web/src/commandBar.test.ts | 14 ++ web/src/commandBar.ts | 8 +- web/src/types.ts | 5 +- 14 files changed, 442 insertions(+), 36 deletions(-) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index cac1729..3f5ecb5 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon let status = match s.status { SessionStatus::Running => "running", SessionStatus::Dead => "dead", + SessionStatus::Dormant => "dormant", }; let activity = match s.activity { ActivityState::Active => "active", diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index f1633bc..416ef3b 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -62,7 +62,10 @@ pub struct SessionRecord { /// `base_repo` from the original `WorktreeSpec`. Needed because `git /// worktree remove` is invoked from the base repo. pub worktree_base_repo: Option, - pub bridge: PtyBridge, + /// The PTY bridge feeding this session's attach stream. `None` for a + /// `Dormant` session (#162): there is no pane behind it, so there is + /// nothing to pipe and nothing to attach to. + pub bridge: Option, /// Agent-reported activity (#105) still within its TTL. While in force it /// outranks the activity pass's own inference; when it lapses, inference /// resumes and [`Session::activity_detail`] is cleared with it. This @@ -291,7 +294,10 @@ impl SessionManager { /// unknown to this daemon. pub async fn subscribe_pty(&self, id: Uuid) -> Option>> { let sessions = self.inner.sessions.lock().await; - sessions.get(&id).map(|r| r.bridge.subscribe()) + sessions + .get(&id) + .and_then(|r| r.bridge.as_ref()) + .map(|b| b.subscribe()) } /// Subscribe to a session's PTY broadcast and return the replay buffer @@ -310,19 +316,26 @@ impl SessionManager { let (result, updated) = { let mut sessions = self.inner.sessions.lock().await; let rec = sessions.get_mut(&id)?; - rec.session.last_focused_at = Some(Utc::now()); - let updated = rec.session.clone(); + let SessionRecord { + session, bridge, .. + } = &mut *rec; + // #162: a dormant session has no bridge and no pane — nothing was + // ever piped for it, so there is no stream to hand back and the + // attach reads as unknown. Checked before anything is stamped. + let bridge = bridge.as_ref()?; + session.last_focused_at = Some(Utc::now()); + let updated = session.clone(); // For a live session, tmux's own screen beats the byte tail: the // tail starts mid-stream, so a full-screen program (an agent TUI) // replayed from it renders as garbage until something forces a // repaint. A dead session has no pane left to capture, so it // keeps the byte tail. - let alive = rec.session.status == SessionStatus::Running; + let alive = session.status == SessionStatus::Running; // The capture shells out to tmux while the replay lock is held, // which parks the reader for the length of one tmux round-trip. // That only happens on attach, and the alternative is a seam in // the stream. - let result = rec.bridge.subscribe_with_snapshot(|| { + let result = bridge.subscribe_with_snapshot(|| { if !alive { return None; } @@ -499,7 +512,7 @@ impl SessionManager { fifo_path, worktree_path, worktree_base_repo, - bridge, + bridge: Some(bridge), reported_activity: None, settled: false, #[cfg(feature = "haiku-autoname")] @@ -707,7 +720,7 @@ impl SessionManager { // directory blocks that branch forever (#54 review). worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), - bridge, + bridge: Some(bridge), reported_activity: None, settled: false, #[cfg(feature = "haiku-autoname")] @@ -722,16 +735,89 @@ impl SessionManager { .send(CatalogEvent::SessionAdded { session }); adopted += 1; } + self.register_dormant().await; Ok(adopted) } - /// Forward keystrokes to a session's pane. A dead session is read-only — - /// its pane is gone, so this is a no-op rather than a tmux failure the - /// client would have to interpret. + /// Second recovery pass (#162): register every sidecar tmux had nothing to + /// adopt as a [`SessionStatus::Dormant`] session. + /// + /// A host reboot kills the tmux server, so the first pass runs over an + /// empty list and every session would otherwise disappear from the API + /// while its sidecar sat untouched on disk. A dormant record keeps the id, + /// name, cwd, command, worktree and `created_at` — everything the sidecar + /// holds — so the operator still has a record of what was running. It has + /// no tmux session and no [`PtyBridge`] behind it: it cannot be attached + /// to, cannot be typed into, and is skipped by both watcher passes. The + /// only thing left to do with one is dismiss it, which removes the sidecar + /// so the next recovery does not bring it back. + /// + /// Activity is not carried over: whatever the session was doing, it is not + /// doing it now. The rest of the sidecar is left as written. + async fn register_dormant(&self) { + let stored = match self.inner.sidecar.load_all() { + Ok(stored) => stored, + Err(e) => { + tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed"); + return; + } + }; + let mut registered = Vec::new(); + { + let mut sessions = self.inner.sessions.lock().await; + for mut session in stored { + if sessions.contains_key(&session.id) { + // Adopted live by the first pass; its sidecar is on disk too. + continue; + } + let id = session.id; + session.status = SessionStatus::Dormant; + session.foreground = None; + session.activity = ActivityState::Idle; + session.activity_detail = None; + sessions.insert( + id, + SessionRecord { + session: session.clone(), + fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")), + // Still known, so a dismissal cleans the worktree up — + // the same reason recovery restores it for a live + // session (#54 review). + worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), + worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), + bridge: None, + reported_activity: None, + settled: false, + #[cfg(feature = "haiku-autoname")] + naming: false, + focused_since: None, + awaiting_suppressed: false, + }, + ); + registered.push(session); + } + } + if !registered.is_empty() { + tracing::info!( + dormant = registered.len(), + "registered sessions with no live tmux as dormant" + ); + } + for session in registered { + let _ = self + .inner + .events + .send(CatalogEvent::SessionAdded { session }); + } + } + + /// Forward keystrokes to a session's pane. A dead or dormant session is + /// read-only — its pane is gone, so this is a no-op rather than a tmux + /// failure the client would have to interpret. pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> { match self.status(id).await { None => return Err(SessionError::NotFound(id)), - Some(SessionStatus::Dead) => return Ok(()), + Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()), Some(SessionStatus::Running) => {} } let tmux = self.inner.tmux.clone(); @@ -744,13 +830,15 @@ impl SessionManager { /// Type `text` into a session's pane, with an Enter after it when /// `submit` is set. /// - /// Unlike [`Self::send_keys`], a dead session is an error: the caller - /// asked for text to land somewhere, and a silent success would let a - /// script believe an agent had been told something it never heard. + /// Unlike [`Self::send_keys`], a dead or dormant session is an error: the + /// caller asked for text to land somewhere, and a silent success would let + /// a script believe an agent had been told something it never heard. pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> { match self.status(id).await { None => return Err(SessionError::NotFound(id)), - Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)), + Some(SessionStatus::Dead | SessionStatus::Dormant) => { + return Err(SessionError::NotRunning(id)) + } Some(SessionStatus::Running) => {} } let tmux = self.inner.tmux.clone(); @@ -768,12 +856,12 @@ impl SessionManager { Ok(()) } - /// Resize a session's window. No-op for a dead session, as with + /// Resize a session's window. No-op for a dead or dormant session, as with /// [`Self::send_keys`]. pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { match self.status(id).await { None => return Err(SessionError::NotFound(id)), - Some(SessionStatus::Dead) => return Ok(()), + Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()), Some(SessionStatus::Running) => {} } let tmux = self.inner.tmux.clone(); @@ -1174,7 +1262,11 @@ async fn run_activity_pass(inner: &Arc) { rec.session.activity_detail = None; } } - let idle = rec.bridge.idle_for(); + // Only `Running` sessions reach this pass, and every one of them + // has a bridge; a dormant session has neither pane nor bridge. + let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else { + continue; + }; // #105: while a report is in force it supplies the state and // nothing here may overwrite it; only an unreported session is // decided by inference. @@ -1292,8 +1384,10 @@ async fn run_blocked_work_pass(inner: &Arc) { /// alone and stays attached to the dead record for a later forced dismissal /// (#81), which leaves it on disk too. /// -/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] -/// adopts only what tmux still holds. +/// A dead session does not come back as dead across a daemon restart: its +/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it +/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither +/// has a pane behind it. async fn mark_session_dead(inner: &Arc, id: Uuid) { let flipped = { let mut sessions = inner.sessions.lock().await; @@ -1302,7 +1396,9 @@ async fn mark_session_dead(inner: &Arc, id: Uuid) { record.session.status = SessionStatus::Dead; // Release anyone attached; the scrollback survives for a later // attach. - record.bridge.seal(); + if let Some(bridge) = record.bridge.as_mut() { + bridge.seal(); + } let session = record.session.clone(); let worktree = record .worktree_base_repo diff --git a/crates/maestro-daemon/tests/session_manager.rs b/crates/maestro-daemon/tests/session_manager.rs index 650dc34..441bd8b 100644 --- a/crates/maestro-daemon/tests/session_manager.rs +++ b/crates/maestro-daemon/tests/session_manager.rs @@ -140,6 +140,140 @@ async fn recover_adopts_existing_tmux_sessions() { assert_eq!(snap[0].command, "sleep 30"); } +/// #162: a host reboot kills the tmux server, so recovery finds nothing to +/// adopt while every sidecar is still on disk. Those sessions come back as +/// `Dormant` — a record of what was running — rather than vanishing from the +/// API and leaving the operator with nothing. +#[tokio::test] +async fn recover_registers_orphaned_sidecars_as_dormant() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + let mut metadata = HashMap::new(); + metadata.insert( + maestro_protocol::NAME_KEY.to_string(), + serde_json::Value::String("after-reboot".into()), + ); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata, + worktree: None, + }) + .await + .expect("create"); + + // The reboot: the tmux server is gone, the sidecar is untouched. + fx.tmux.tmux.kill_server().expect("kill tmux server"); + + let revived = revive(&fx); + let adopted = revived.recover().await.expect("recover"); + assert_eq!(adopted, 0, "no live tmux session left to adopt"); + + let snap = revived.snapshot().await; + assert_eq!(snap.len(), 1, "the sidecar's session is still listed"); + assert_eq!(snap[0].id, s.id); + assert_eq!(snap[0].status, SessionStatus::Dormant); + assert_eq!(snap[0].command, "sleep 30"); + assert_eq!(snap[0].cwd, s.cwd); + assert_eq!(snap[0].created_at, s.created_at); + assert_eq!( + snap[0] + .metadata + .get(maestro_protocol::NAME_KEY) + .and_then(|v| v.as_str()), + Some("after-reboot"), + ); +} + +/// #162: a session tmux still holds is adopted `Running` by the first pass and +/// must not be re-registered as dormant by the second — the sidecar is on disk +/// for both. +#[tokio::test] +async fn recover_does_not_mark_adopted_sessions_dormant() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let live = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create live"); + let orphan = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 31".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create orphan"); + // Only the second session loses its pane. + fx.tmux.tmux.kill_session(orphan.id).expect("kill session"); + + let revived = revive(&fx); + assert_eq!(revived.recover().await.expect("recover"), 1); + + let mut snap = revived.snapshot().await; + snap.sort_by_key(|s| s.command.clone()); + assert_eq!(snap.len(), 2); + assert_eq!(snap[0].id, live.id); + assert_eq!(snap[0].status, SessionStatus::Running); + assert_eq!(snap[1].id, orphan.id); + assert_eq!(snap[1].status, SessionStatus::Dormant); +} + +/// #162: dismissing a dormant session is the one thing that can be done to it +/// — no tmux call to fail, and the sidecar goes with it so the next recovery +/// does not resurrect it. +#[tokio::test] +async fn dormant_session_can_be_dismissed() { + let fx = DaemonFixture::new(); + let cwd = TempDir::new().unwrap(); + + let s = fx + .manager + .create(CreateSessionRequest { + cwd: cwd.path().to_path_buf(), + command: "sleep 30".into(), + env: HashMap::new(), + metadata: HashMap::new(), + worktree: None, + }) + .await + .expect("create"); + fx.tmux.tmux.kill_server().expect("kill tmux server"); + + let revived = revive(&fx); + revived.recover().await.expect("recover"); + revived.kill(s.id, false).await.expect("dismiss"); + + assert!(!revived.has(s.id).await); + assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists()); +} + +/// A second `SessionManager` over the same dirs and the same tmux server — +/// what a daemon restart looks like from inside a test. +fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager { + maestro_daemon::SessionManager::new( + Arc::new(fx.tmux.tmux.clone()), + maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()), + maestro_daemon::worktree::WorktreeManager::new(None), + fx.fifo_dir(), + ) +} + /// Regression for NAP-253: the very first bytes a freshly-spawned command /// writes must reach the replay buffer. Before the fix, `pipe-pane` was a /// separate tmux invocation that ran after `new-session` + an async FIFO diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 465a3b2..45bcb3f 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -123,10 +123,38 @@ fn epoch() -> DateTime { DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +/// Lifecycle of a session as the daemon knows it. +/// +/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling +/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the +/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is +/// one this client must not treat as having a live pane to type into. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum SessionStatus { Running, Dead, + /// Known from its sidecar, with no tmux session behind it (#162). The + /// daemon registers these at recovery time for every sidecar tmux had + /// nothing to adopt — after a host reboot, that is every session there + /// was. There is no pane and no PTY bridge: a dormant session is a record + /// of what was running, listed so the operator still has it, and the only + /// thing that can be done to it is dismissing it. + Dormant, +} + +impl<'de> Deserialize<'de> for SessionStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(match s.as_str() { + "Running" => Self::Running, + "Dormant" => Self::Dormant, + // Known or not, any other spelling degrades to the inert variant. + _ => Self::Dead, + }) + } } /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The @@ -1059,6 +1087,33 @@ mod tests { serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::json!("Dead") ); + assert_eq!( + serde_json::to_value(SessionStatus::Dormant).unwrap(), + serde_json::json!("Dormant") + ); + } + + // #162: a session with no tmux behind it still rides the wire whole. + #[test] + fn dormant_session_round_trips() { + let mut s = sample_session(); + s.status = SessionStatus::Dormant; + assert_eq!(s, round_trip(&s)); + } + + // #162: a `SessionStatus` this client does not know must degrade to the + // inert variant, not fail the whole `Session` the way a derived + // `Deserialize` would. + #[test] + fn unknown_session_status_degrades_to_dead() { + assert_eq!( + serde_json::from_value::(serde_json::json!("Hibernating")).unwrap(), + SessionStatus::Dead + ); + let mut value = serde_json::to_value(sample_session()).unwrap(); + value["status"] = serde_json::json!("Hibernating"); + let s: Session = serde_json::from_value(value).expect("session survives unknown status"); + assert_eq!(s.status, SessionStatus::Dead); } #[test] diff --git a/crates/maestro-tui/src/command_bar.rs b/crates/maestro-tui/src/command_bar.rs index 3b2081b..e70fcb0 100644 --- a/crates/maestro-tui/src/command_bar.rs +++ b/crates/maestro-tui/src/command_bar.rs @@ -194,7 +194,9 @@ pub enum BarOutcome { pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { let mut ordered: Vec<&Session> = sessions.iter().collect(); ordered.sort_by(|a, b| { - let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); + // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant + // (#162) — sorts after the sessions you could actually attach to. + let dead = |s: &Session| u8::from(s.status != SessionStatus::Running); dead(a) .cmp(&dead(b)) .then_with(|| { @@ -319,7 +321,7 @@ impl CommandBar { .map(|s| BarEntry { candidate: MatchCandidate::Session(session_candidate(s, home_dir)), target: BarTarget::Session(s.id), - dead: s.status == SessionStatus::Dead, + dead: s.status != SessionStatus::Running, chords: Vec::new(), }) .collect(); @@ -889,6 +891,23 @@ mod tests { assert!(bar.rows()[1].dead); } + /// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by + /// which way the pane went away — a dormant session sorts with the dead. + #[test] + fn dormant_sessions_sort_after_live_ones_however_recent() { + let mut dormant = named(1, "dormant"); + dormant.status = SessionStatus::Dormant; + dormant.last_focused_at = Some(at(99)); + let live = named(2, "live"); + + let bar = CommandBar::new(&[dormant, live], &[], "/home/me"); + assert_eq!( + ids(bar.rows()), + vec![Uuid::from_u128(2), Uuid::from_u128(1)] + ); + assert!(bar.rows()[1].dead); + } + #[test] fn typing_ranks_through_the_matcher_and_returns_ranges() { let mut bar = CommandBar::new( diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index e739907..4cada73 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -268,6 +268,8 @@ impl Sidebar { fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec> { let glyph = match (s.status, self.attached.contains(&s.id)) { (SessionStatus::Dead, _) => "✗", + // #162: known from its sidecar, nothing running behind it. + (SessionStatus::Dormant, _) => "◌", (SessionStatus::Running, true) => "●", (SessionStatus::Running, false) => "○", }; @@ -322,6 +324,7 @@ impl Sidebar { // on line 1; the kill confirm names the specifics. let blocked = s.blocked_work.filter(|b| !b.is_clear()); let dead = s.status == SessionStatus::Dead; + let dormant = s.status == SessionStatus::Dormant; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let fill = self.card_fill(s, focused); let base = card_base(fill, focused); @@ -361,6 +364,9 @@ impl Sidebar { if dead { tail.push(Span::styled(" exited", muted)); } + if dormant { + tail.push(Span::styled(" dormant", muted)); + } // docs/SIDEBAR.md: the card names the program, never its arguments. let command = Span::styled(format!(" {}", command_program(&s.command)), muted); @@ -1068,6 +1074,39 @@ mod tests { ); } + /// #162, docs/SIDEBAR.md: a dormant session is known but not running — + /// its own glyph on line 1 and the muted `dormant` fragment on line 2, so + /// it never reads as something that exited. + #[test] + fn dormant_session_reads_as_known_but_not_running() { + let mut s = session_named(1, "claude"); + s.status = SessionStatus::Dormant; + s.activity_since = Utc::now() - Duration::seconds(5); + let sb = Sidebar { + sessions: vec![s], + highlight: None, + attached: Default::default(), + pane_session: None, + }; + let all = rows(&draw(&sb, 28, 8)); + let idx = all.iter().position(|l| l.contains("claude")).unwrap(); + assert!( + all[idx].contains('\u{25cc}'), + "expected the dormant glyph in: {:?}", + all[idx] + ); + assert!( + all[idx + 1].contains("dormant"), + "expected dormant fragment in: {:?}", + all[idx + 1] + ); + assert!( + !all[idx + 1].contains("exited"), + "a dormant session never reads as exited: {:?}", + all[idx + 1] + ); + } + /// docs/SIDEBAR.md: line 2 names the program, never its arguments. #[test] fn meta_line_drops_the_commands_arguments() { diff --git a/docs/COMMAND-BAR.md b/docs/COMMAND-BAR.md index 0fdae05..5961bd4 100644 --- a/docs/COMMAND-BAR.md +++ b/docs/COMMAND-BAR.md @@ -123,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at `POST /api/sessions/:id/focus` when it refocuses a pane that is already open. **A session that has never been focused sorts after every session that has**, -and among those, most recently created first. Dead sessions sort after live -ones regardless of recency: a session you cannot attach to is not where you -just were. +and among those, most recently created first. Sessions with no live pane — +`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a +session you cannot attach to is not where you just were. Under `>` an empty query lists every command in registry order. Under `~` it lists recent dirs, most recent first, then the project list. diff --git a/docs/RESTART.md b/docs/RESTART.md index e6f7286..246bd1b 100644 --- a/docs/RESTART.md +++ b/docs/RESTART.md @@ -68,6 +68,26 @@ resolves, so a blocked client is never a trapped one. It clears itself the moment a probe succeeds — there is no "retry" button, because the client is already retrying. +## What survives, and what comes back dormant + +A daemon restart leaves tmux alone, so recovery re-adopts every +`maestro-` session tmux still holds and the operator sees no change +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 +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 +resized, and neither watcher pass touches it. It is a record of what was +running, and the only thing left to do with one is dismiss it, which removes +the sidecar so the next recovery does not bring it back. + +`SessionStatus` is a wire type, so it deserializes leniently on both clients: +a spelling this build does not know reads as `Dead` rather than failing the +whole session. Clients render a dormant session as known-but-not-running — +see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks. + ## Timing - **Grace window** — 1200 ms between the socket dropping and the overlay diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 8cc4fb2..b21d210 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -56,7 +56,7 @@ itself is not. Line 2 reads left to right: ``` -[] [] [ | exited] +[] [] [ | exited | dormant] ``` Its minimum content is therefore just the command. @@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists. while an agent-reported state is in force (see Precedence): when the report lapses at its TTL or is replaced, the detail goes with it. The detail truncates with `…` ahead of the trailing fragments — a long detail never -pushes ``, ``, or `exited` off the line, and neither -client ever lets it wrap. +pushes ``, ``, `exited` or `dormant` off the line, and +neither client ever lets it wrap. + +`dormant` (#162) is the counterpart of `exited` for a session the daemon knows +only from its sidecar: recovery found no tmux session behind it, so it has no +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. `` is the blocked-work breakdown (#83) in diff notation, replacing the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only @@ -209,7 +215,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a highlighted **folder** row — a folder previews nothing, so it has no fill to step up to. -The duration text inherits its state's colour; the exited and command +The duration text inherits its state's colour; the exited, dormant and command fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they take the `active` and `error` roles of the same surface. diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index 9447f5c..47dde0d 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -414,6 +414,19 @@ describe("session cards (#107)", () => { expect(line2?.textContent).toContain("exited"); }); + // #162: a session recovered with no tmux behind it is known but not running, + // and never reads as something that exited. + it("shows dormant on line 2 for dormant sessions", () => { + const s = session({ + id: "aaaaaaaa-0000-0000-0000-000000000118", + status: "Dormant", + }); + const el = renderSidebar(storeWith([s])).el; + const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + expect(line2?.textContent).toContain("dormant"); + expect(line2?.textContent).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", diff --git a/web/src/catalog.ts b/web/src/catalog.ts index ab8364c..1a4e4c7 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -113,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string { function metaLine(s: Session, now: number): string { const dead = s.status === "Dead"; + // #162: known from its sidecar with nothing running behind it — a distinct + // fragment, since it never exited. + const dormant = s.status === "Dormant"; const blockers = sessionBlockers(s); const slug = activityColorSlug(s.activity ?? "Idle"); const held = activityHeldMs(s.activity_since, now); @@ -127,6 +130,7 @@ function metaLine(s: Session, now: number): string { commandFragment(s.command), blockers ? blockedFragment(blockers) : "", dead ? `exited` : "", + dormant ? `dormant` : "", ]; return `${fragments.join("")}`; } diff --git a/web/src/commandBar.test.ts b/web/src/commandBar.test.ts index abb8bd9..aff6aae 100644 --- a/web/src/commandBar.test.ts +++ b/web/src/commandBar.test.ts @@ -99,6 +99,20 @@ describe("empty-query ordering", () => { ]); }); + // #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which + // way the pane went away. + it("sorts dormant sessions after live ones, like dead ones", () => { + const dormantRecent = session("dormant", { + status: "Dormant", + last_focused_at: "2026-06-01T00:00:00Z", + }); + const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" }); + expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([ + "live", + "dormant", + ]); + }); + it("treats a null last_focused_at as never focused", () => { const nulled = session("nulled", { last_focused_at: null }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); diff --git a/web/src/commandBar.ts b/web/src/commandBar.ts index cd37d06..e1d78f8 100644 --- a/web/src/commandBar.ts +++ b/web/src/commandBar.ts @@ -142,8 +142,10 @@ function createdAt(s: Session): number { * matcher's last tie-break preserves. */ export function orderSessionsForBar(sessions: readonly Session[]): Session[] { return [...sessions].sort((a, b) => { - const deadA = a.status === "Dead" ? 1 : 0; - const deadB = b.status === "Dead" ? 1 : 0; + // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162) + // — sorts after the sessions you could actually attach to. + const deadA = a.status === "Running" ? 0 : 1; + const deadB = b.status === "Running" ? 0 : 1; if (deadA !== deadB) return deadA - deadB; const focusA = focusedAt(a); @@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement { row.dataset.kind = candidate.kind; if (candidate.kind === "session") { row.dataset.sessionId = candidate.session.id; - if (candidate.session.status === "Dead") row.classList.add("is-dead"); + if (candidate.session.status !== "Running") row.classList.add("is-dead"); } else if (isActionCandidate(candidate)) { row.dataset.actionId = candidate.action.id; } else if (candidate.kind === "command") { diff --git a/web/src/types.ts b/web/src/types.ts index 091883e..681b372 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js"; export type Uuid = string; export type IsoTimestamp = string; -export type SessionStatus = "Running" | "Dead"; +/** `Dormant` (#162) is a session the daemon knows only from its sidecar — + * recovered after the tmux server died under it, so there is no pane behind it + * and nothing to attach to. Listed, never live. */ +export type SessionStatus = "Running" | "Dead" | "Dormant"; /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). * Absent on payloads from daemons that predate the field. `Error` is only set