Compare commits

..
Author SHA1 Message Date
Miguel Palhas 85ddca5e7b fix(maestro): assert on the git shim log, not a wall-clock budget
a_session_without_a_worktree_costs_no_probe waited up to 5s for the
watcher pass to run, using that wait's timeout panic as an implicit
proxy for "no probe happened". Under a loaded full-workspace run the
watcher task can be scheduled well past 5s, so the test flaked.

Poll the shim log directly for A's probe instead of a derived snapshot
state, and use the wait only as a 30s hang guard. The actual property
- B was never probed - is asserted unconditionally on the log content,
independent of timing.

Fixes #173.
2026-09-04 09:43:54 +01:00
Miguel Palhas fed0c102a5 merge: persist PTY scrollback across a reboot (#171)
Closes #171
2026-09-04 09:41:09 +01:00
Miguel PalhasandClaude Opus 5 5a47f6fbf6 feat(maestro): persist PTY scrollback across a reboot
A resumed session opened on a blank pane: the agent's conversation came
back, the terminal history the operator was reading did not. Each PTY
bridge's replay ring is now mirrored to `<state_dir>/scrollback/<id>.bin`
and handed to the new bridge on resume, and on the recovery pass that
re-adopts a session tmux still holds — that one lost its ring with the
previous daemon too. Restored bytes go out ahead of the live ones, and
are dropped once the ring holds a full budget of new output of its own.

Flushed every 10 seconds by the liveness watcher, and once more when a
bridge is sealed on exit. The timer is the part that matters: a host
reboot runs no exit path at all, so a scrollback written only on a clean
shutdown is missing in exactly the case this exists for. Ten seconds
bounds the loss to the last few lines — this is visual continuity, not
state — and costs at most one file write per active session per tick,
since a session whose bridge has read nothing since the last flush is
skipped.

The file is capped at the existing `pty_replay_bytes` budget rather than
a second one, is deleted on kill and when recovery prunes a sidecar, and
a missing, unreadable or oversized one degrades to an empty pane instead
of failing the resume.

Closes #171

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:37:53 +01:00
Miguel Palhas e7830d15b0 merge: replace agent_session_id on a new report (#176)
Closes #176
2026-09-04 09:29:49 +01:00
Miguel Palhas 0fd107be77 merge: dormant cards and resume in the web sidebar (#168)
Closes #168
2026-09-04 09:26:02 +01:00
Miguel Palhas 915e408c16 docs(maestro): describe the dormant card, pane and ordering
docs/SIDEBAR.md: the dormant card's muted rendering, its resume
action, and its lowest-precedence ordering.
docs/PREVIEW.md: a session that isn't Running never gets a live
attach through the preview path either — its pane reads the same as
its card.
2026-09-04 09:19:23 +01:00
Miguel Palhas 577434dc43 style(maestro): style the dormant card and pane
Muted card foreground and resume-button hover to match spawn-similar
(#168); the content pane's metadata-and-resume placeholder in place of
a live terminal.
2026-09-04 09:19:23 +01:00
Miguel Palhas cc1e7b5f98 fix(maestro): never open a live pane for a session that isn't running
Integration review (#168): the content pane opened a pane for
whatever the sidebar highlighted with no status check. After a host
reboot every session is dormant, so arrowing down the sidebar opened
a pane per session that immediately died with WS 1006 (the daemon
answers a bridgeless attach with 404/409, never a completed
handshake).

Gate every path that fills a pane (preview, open, mobile's single
pane) on the session's own status: a session that isn't Running gets
the same metadata-and-resume view its sidebar card shows
(docs/SIDEBAR.md, docs/PREVIEW.md) instead of an attach attempt. The
shell also reacts to onNeverAttached (previous commit) by dropping the
dead connection and re-rendering, and to an open session's
Running-ness flipping — most commonly a resume completing — so a
placeholder and a live pane swap in step with the catalog.
2026-09-04 09:19:19 +01:00
Miguel Palhas c29d6f4339 feat(maestro): resume a dormant session from its sidebar card
Two integration-review fixes plus the card's own resume action
(#168):

- dataset.status and the aria-label read a two-way running/dead test,
  so a dormant card announced itself as "exited" while its visible
  dormant flag disagreed. Both now read the session's own three-way
  status.
- A dormant card is a muted variant (docs/SIDEBAR.md): spawn-similar
  is replaced by a resume action (button, context menu) that calls
  POST /api/sessions/:id/resume — there is nothing running to clone
  from a dormant session, but resuming it in place makes sense. Kill
  is unchanged: it already dismisses a dormant session by removing
  its sidecar.
2026-09-04 09:19:13 +01:00
Miguel Palhas 938dd62648 fix(maestro): stop retrying a WS attach that never opened
Integration review (#168): a rejected attach handshake — a dormant
session's 409 not_attachable, most likely — looks identical to a
dropped stream from the WebSocket API (close code 1006, no reason).
shouldRetryClose kept retrying it on the catalog's Running/Dead check
alone, which loops for as long as the catalog hasn't caught up with
the session going dormant.

Track whether the socket ever reached `open`; a handshake that never
did is a rejection, not a blip, so it is never retried regardless of
catalog status. The caller (wired up in the next commit) gets a
distinct onNeverAttached callback instead of onClosed, so it can offer
the resume affordance rather than assuming the session is gone.
2026-09-04 09:19:09 +01:00
Miguel Palhas 8269bcabcc feat(maestro): sort dormant sessions after live ones in the sidebar
docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
sidebar tree, so a rebooted host's live work never sorts under a pile
of dormant cards.
2026-09-04 09:19:03 +01:00
26 changed files with 1190 additions and 50 deletions
+3
View File
@@ -84,6 +84,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
config.spawn_history_path(),
config.spawn_history_days,
))
// #171: mirror PTY scrollback so a resumed session opens on what it was
// showing, instead of a blank pane.
.with_scrollback(config.scrollback_dir())
.with_boot_id(
config.boot_id_path(),
std::sync::Arc::new(crate::boot_id::ProcBootId),
+6
View File
@@ -117,6 +117,12 @@ impl DaemonConfig {
self.state_dir.join("spawn-history.jsonl")
}
/// Where PTY scrollback is mirrored so a resumed session opens on what
/// it printed before (#171): `<state_dir>/scrollback`.
pub fn scrollback_dir(&self) -> PathBuf {
self.state_dir.join("scrollback")
}
/// Where recovery records the kernel boot id it saw last (#164):
/// `<state_dir>/boot_id`.
pub fn boot_id_path(&self) -> PathBuf {
+1
View File
@@ -26,6 +26,7 @@ pub mod projects;
pub mod pty;
pub mod release;
pub mod resume;
pub mod scrollback;
pub mod session;
pub mod sidecar;
pub mod spawn_history;
+143 -1
View File
@@ -21,6 +21,11 @@
//! one-shot prefix before joining the live broadcast, so attaching to an
//! idle session immediately shows the last screenful instead of a blank
//! terminal.
//! - #171: a bridge can be started with scrollback restored from disk — what
//! the session printed before the daemon (or the host) went away. Those
//! bytes are handed to subscribers ahead of the live ones until the ring
//! has a full budget of new output of its own, at which point the restored
//! history is older than anything the budget covers and is dropped.
use std::collections::VecDeque;
use std::ffi::CString;
@@ -58,6 +63,10 @@ const READ_BUF_BYTES: usize = 4096;
#[derive(Debug)]
pub struct PtyBridge {
fifo_path: PathBuf,
/// Scrollback from a previous life of this session (#171), replayed
/// ahead of the ring. Empty for a fresh spawn. Never appended to, so it
/// can never duplicate a byte the ring also holds.
restored: Vec<u8>,
/// `None` once [`Self::seal`] has run: live subscribers see the channel
/// close, but the replay buffer stays readable.
sender: Option<broadcast::Sender<Vec<u8>>>,
@@ -79,6 +88,21 @@ impl PtyBridge {
/// Variant that lets the caller pick a custom replay-buffer cap (bytes).
/// Pass `0` to disable replay (subscribers see only live bytes).
pub async fn start_with_replay(fifo_path: PathBuf, replay_bytes: usize) -> io::Result<Self> {
Self::start_restoring(fifo_path, replay_bytes, Vec::new()).await
}
/// As [`Self::start_with_replay`], seeded with `restored` scrollback read
/// back from disk (#171). The seed is truncated to the same
/// `replay_bytes` budget the ring uses — there is one budget here, not
/// two — and is dropped entirely when replay is disabled.
pub async fn start_restoring(
fifo_path: PathBuf,
replay_bytes: usize,
mut restored: Vec<u8>,
) -> io::Result<Self> {
if restored.len() > replay_bytes {
restored.drain(..restored.len() - replay_bytes);
}
if let Some(parent) = fifo_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
@@ -102,6 +126,7 @@ impl PtyBridge {
});
Ok(Self {
fifo_path,
restored,
sender: Some(sender),
replay,
last_activity,
@@ -155,7 +180,8 @@ impl PtyBridge {
F: FnOnce() -> Option<Vec<u8>>,
{
let guard = self.replay.lock_for_snapshot();
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
let live = capture().unwrap_or_else(|| guard.snapshot());
let snapshot = guard.with_restored_prefix(&self.restored, live);
let rx = match &self.sender {
Some(tx) => tx.subscribe(),
// Sealed: hand back the scrollback plus a receiver that is already
@@ -171,6 +197,25 @@ impl PtyBridge {
(snapshot, rx)
}
/// Everything worth persisting for a later life of this session (#171):
/// the restored scrollback and the ring, trimmed together to the replay
/// budget so the file on disk is never bigger than the ring it mirrors.
pub fn scrollback(&self) -> Vec<u8> {
let guard = self.replay.lock_for_snapshot();
guard.with_restored_prefix(&self.restored, guard.snapshot())
}
/// Total bytes this bridge has appended to its ring. The scrollback flush
/// pass compares it against what it last wrote, so an idle session costs
/// no disk writes at all.
pub fn bytes_seen(&self) -> u64 {
self.replay
.inner
.lock()
.expect("ReplayBuffer poisoned")
.seen
}
/// Stop reading and close the live stream, keeping the replay buffer.
///
/// Attached clients see the channel close — without that a `maestro attach`
@@ -201,6 +246,8 @@ struct ReplayBuffer {
struct ReplayInner {
buf: VecDeque<u8>,
cap: usize,
/// Bytes ever appended, not bytes held — the ring evicts, this does not.
seen: u64,
}
impl ReplayBuffer {
@@ -209,6 +256,7 @@ impl ReplayBuffer {
inner: Arc::new(Mutex::new(ReplayInner {
buf: VecDeque::with_capacity(cap.min(READ_BUF_BYTES * 2)),
cap,
seen: 0,
})),
}
}
@@ -225,6 +273,7 @@ impl ReplayBuffer {
// If the incoming chunk alone exceeds the cap, take only its tail —
// anything in the buffer would be evicted before this finishes anyway.
let take_from = bytes.len().saturating_sub(s.cap);
s.seen += bytes.len() as u64;
let bytes = &bytes[take_from..];
s.buf.extend(bytes);
while s.buf.len() > s.cap {
@@ -247,6 +296,24 @@ struct ReplayGuard<'a> {
}
impl ReplayGuard<'_> {
/// `live` with the restored scrollback (#171) in front of it, while the
/// ring still holds less than a full budget of its own output. Once it
/// holds a full budget, everything restored is older than what the
/// budget covers, so it is dropped rather than kept forever.
fn with_restored_prefix(&self, restored: &[u8], live: Vec<u8>) -> Vec<u8> {
let cap = self.inner.cap;
let held = self.inner.buf.len();
if restored.is_empty() || cap == 0 || held >= cap {
return live;
}
let keep = cap - held;
let start = restored.len().saturating_sub(keep);
let mut out = Vec::with_capacity((restored.len() - start) + live.len());
out.extend_from_slice(&restored[start..]);
out.extend_from_slice(&live);
out
}
fn snapshot(&self) -> Vec<u8> {
let (a, b) = self.inner.buf.as_slices();
let mut out = Vec::with_capacity(a.len() + b.len());
@@ -292,6 +359,7 @@ async fn run_reader(
// never both, never neither.
let mut s = replay.inner.lock().expect("ReplayBuffer poisoned");
if s.cap > 0 {
s.seen += n as u64;
let take_from = n.saturating_sub(s.cap);
s.buf.extend(&buf[take_from..n]);
while s.buf.len() > s.cap {
@@ -345,6 +413,80 @@ fn shell_quote(s: &str) -> String {
mod tests {
use super::*;
async fn bridge(cap: usize, restored: &[u8]) -> (tempfile::TempDir, PtyBridge) {
let dir = tempfile::TempDir::new().expect("tempdir");
let bridge =
PtyBridge::start_restoring(dir.path().join("pty.fifo"), cap, restored.to_vec())
.await
.expect("start bridge");
(dir, bridge)
}
/// Feed the bridge through its own FIFO and wait for the reader to have
/// taken the bytes, so the ring state is deterministic.
async fn feed(bridge: &PtyBridge, bytes: &[u8]) {
use std::io::Write;
let before = bridge.bytes_seen();
let mut f = std::fs::OpenOptions::new()
.write(true)
.open(bridge.fifo_path())
.expect("open fifo for write");
f.write_all(bytes).expect("write fifo");
drop(f);
for _ in 0..400 {
if bridge.bytes_seen() >= before + bytes.len() as u64 {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
panic!("bridge never read the bytes written to its fifo");
}
#[tokio::test]
async fn restored_scrollback_is_replayed_ahead_of_live_bytes() {
let (_dir, bridge) = bridge(64, b"before the reboot\n").await;
feed(&bridge, b"after\n").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"before the reboot\nafter\n");
}
#[tokio::test]
async fn restored_scrollback_is_trimmed_to_the_replay_budget() {
let (_dir, bridge) = bridge(4, b"abcdefghij").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"ghij");
}
#[tokio::test]
async fn restored_scrollback_yields_once_the_ring_holds_a_full_budget() {
let (_dir, bridge) = bridge(4, b"old!").await;
feed(&bridge, b"ab").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(replay, b"d!ab", "prefix plus ring stays inside the budget");
feed(&bridge, b"cd").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert_eq!(
replay, b"abcd",
"restored history is now older than the budget"
);
}
#[tokio::test]
async fn scrollback_carries_the_restored_prefix_and_the_ring() {
let (_dir, bridge) = bridge(8, b"old").await;
feed(&bridge, b"new").await;
assert_eq!(bridge.scrollback(), b"oldnew");
assert_eq!(bridge.bytes_seen(), 3);
}
#[tokio::test]
async fn no_replay_budget_means_no_restored_scrollback() {
let (_dir, bridge) = bridge(0, b"old").await;
let (replay, _rx) = bridge.subscribe_with_replay();
assert!(replay.is_empty());
assert!(bridge.scrollback().is_empty());
}
#[test]
fn replay_buffer_keeps_last_cap_bytes() {
let r = ReplayBuffer::new(4);
+148
View File
@@ -0,0 +1,148 @@
//! Persisted PTY scrollback (#171).
//!
//! The PTY replay ring lives in memory, so everything a session printed is
//! gone the moment the daemon process is. A resumed session then opens on a
//! blank pane: the agent's conversation is back, but the terminal history the
//! operator was reading is not.
//!
//! This store mirrors each bridge's ring to `<state_dir>/scrollback/<id>.bin`,
//! and hands it back when a session gets a new bridge — on resume, and on the
//! recovery pass that re-adopts a session tmux still holds. The file is
//! capped at the same `replay_bytes` budget the in-memory ring uses, so disk
//! cost is bounded by the same number the operator already configures.
//!
//! Nothing here is allowed to fail a resume. The payload is raw PTY bytes
//! with no framing to be corrupt, and a file that is missing, unreadable or
//! oversized degrades to an empty (or truncated) pane rather than an error.
use std::io;
use std::path::{Path, PathBuf};
use uuid::Uuid;
/// Filesystem-backed scrollback store, rooted at a directory.
#[derive(Debug, Clone)]
pub struct ScrollbackStore {
dir: PathBuf,
}
impl ScrollbackStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.bin"))
}
/// Atomically write `bytes` to `<dir>/<id>.bin`, creating the directory if
/// needed. Written through a temp file and renamed, so a reboot in the
/// middle of a flush leaves the previous scrollback intact rather than a
/// half-file.
pub fn write(&self, id: Uuid, bytes: &[u8]) -> io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let tmp = self.dir.join(format!("{id}.bin.tmp"));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, self.path_for(id))
}
/// The stored scrollback for `id`, capped to the last `budget` bytes.
///
/// Never fails: a missing file, an unreadable one, or a `budget` of zero
/// all read as "no scrollback", which is exactly the blank pane the
/// operator had before this existed.
pub fn read(&self, id: Uuid, budget: usize) -> Vec<u8> {
if budget == 0 {
return Vec::new();
}
let path = self.path_for(id);
let mut bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(error = ?e, %id, path = %path.display(), "scrollback: unreadable, resuming with a blank pane");
return Vec::new();
}
};
// A file written under a larger budget than this daemon now runs
// with keeps its tail, the same end of the stream the ring would.
if bytes.len() > budget {
bytes.drain(..bytes.len() - budget);
}
bytes
}
/// Delete the scrollback for `id`. No-op if it is not there.
pub fn delete(&self, id: Uuid) -> io::Result<()> {
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, ScrollbackStore) {
let dir = tempfile::TempDir::new().expect("tempdir");
let store = ScrollbackStore::new(dir.path().join("scrollback"));
(dir, store)
}
#[test]
fn round_trips_bytes() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"hello world").expect("write");
assert_eq!(store.read(id, 64), b"hello world");
}
#[test]
fn a_missing_file_reads_as_empty() {
let (_dir, store) = store();
assert!(store.read(Uuid::new_v4(), 64).is_empty());
}
#[test]
fn a_file_over_budget_keeps_its_tail() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"abcdefghij").expect("write");
assert_eq!(store.read(id, 4), b"ghij");
}
#[test]
fn a_zero_budget_reads_as_empty() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"abcdefghij").expect("write");
assert!(store.read(id, 0).is_empty());
}
#[test]
fn delete_removes_the_file_and_tolerates_a_missing_one() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"gone soon").expect("write");
store.delete(id).expect("delete");
assert!(!store.path_for(id).exists());
store.delete(id).expect("second delete is a no-op");
}
#[test]
fn a_second_write_replaces_the_first() {
let (_dir, store) = store();
let id = Uuid::new_v4();
store.write(id, b"first").expect("write");
store.write(id, b"second").expect("rewrite");
assert_eq!(store.read(id, 64), b"second");
assert!(!store.dir().join(format!("{id}.bin.tmp")).exists());
}
}
+150 -4
View File
@@ -23,6 +23,7 @@ use uuid::Uuid;
use crate::boot_id::BootIdSource;
use crate::panes::Panes;
use crate::pty::{pipe_pane_command, PtyBridge};
use crate::scrollback::ScrollbackStore;
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError;
@@ -44,6 +45,16 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
/// attempt, which is all #83 needs.
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
/// How often the liveness watcher mirrors PTY scrollback to disk (#171).
///
/// A host reboot gives no warning and runs no exit path, so this interval is
/// the whole guarantee: whatever a session printed in the last few seconds
/// before the power went is what a resumed pane is missing. Ten seconds keeps
/// that loss cosmetic — scrollback is visual continuity, not state — while
/// costing at most one file write per *active* session per tick, since a
/// session whose bridge read nothing is skipped entirely.
pub const DEFAULT_SCROLLBACK_INTERVAL: Duration = Duration::from_secs(10);
/// Gap between the text of a `send` and the Enter that submits it.
///
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
@@ -97,6 +108,10 @@ pub struct SessionRecord {
/// glow, this holds through focus loss until the session does new work.
/// Daemon-local; never rides the wire.
pub awaiting_suppressed: bool,
/// [`PtyBridge::bytes_seen`] as of this session's last scrollback flush
/// (#171). The flush pass skips a session whose bridge has read nothing
/// since, so an idle session costs no disk writes.
pub flushed_bytes: u64,
}
#[derive(Debug, thiserror::Error)]
@@ -206,6 +221,10 @@ struct Inner {
/// [`ResumeOnBoot::Dormant`] with no presets: register and wait.
resume_policy: ResumeOnBoot,
resume_presets: Vec<maestro_protocol::SpawnPreset>,
/// Where PTY scrollback is mirrored so it survives the daemon, and the
/// host (#171). `None` — the default — means scrollback is not persisted
/// at all: a resumed session opens on a blank pane, exactly as before.
scrollback: Option<ScrollbackStore>,
}
/// Where recovery persists the boot id it last saw, and how it reads the
@@ -266,6 +285,7 @@ impl SessionManager {
boot_id: None,
resume_policy: ResumeOnBoot::default(),
resume_presets: Vec::new(),
scrollback: None,
}),
}
}
@@ -303,6 +323,21 @@ impl SessionManager {
/// # Panics
///
/// If the handle has already been cloned.
/// Persist PTY scrollback under `dir` (#171), so a resumed session comes
/// back showing what it printed before the daemon — or the host — went
/// away. Builder, called once right after construction like the other
/// `with_*` methods. Without it nothing is written, read or deleted.
///
/// # Panics
///
/// If the handle has already been cloned; see [`Self::with_spawn_history`].
pub fn with_scrollback(mut self, dir: PathBuf) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_scrollback must be called before the SessionManager is cloned");
inner.scrollback = Some(ScrollbackStore::new(dir));
self
}
pub fn with_boot_id(mut self, path: PathBuf, source: Arc<dyn BootIdSource>) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_boot_id must be called before the SessionManager is cloned");
@@ -601,6 +636,7 @@ impl SessionManager {
focused_since: None,
awaiting_suppressed: false,
resuming: false,
flushed_bytes: 0,
},
);
}
@@ -676,8 +712,14 @@ impl SessionManager {
// Same fifo path as the original spawn: it is derived from the id, and
// `mkfifo` tolerates the stale node the previous run left behind.
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
// #171: the pane is new, but the scrollback the operator was reading
// before the reboot is on disk. It goes in ahead of the live bytes.
let bridge = PtyBridge::start_restoring(
fifo_path.clone(),
self.inner.replay_bytes,
self.restored_scrollback(id),
)
.await?;
let mut session_env = sanitize_env(&session.env)?;
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
@@ -756,6 +798,43 @@ impl SessionManager {
Ok(session)
}
/// Delete the scrollback of a sidecar recovery threw away (#171): the
/// session will never be listed again, so nothing would ever read it.
fn prune_scrollback(&self, id: Uuid) {
if let Some(store) = &self.inner.scrollback {
if let Err(e) = store.delete(id) {
tracing::warn!(error = ?e, %id, "recover: failed to prune scrollback");
}
}
}
/// Scrollback persisted for `id` by an earlier life of this session
/// (#171), capped to the replay budget. Empty when scrollback is not
/// wired, and empty — never an error — when the file is missing or
/// unreadable: a blank pane is the honest fallback, and it is what the
/// operator had before any of this existed.
fn restored_scrollback(&self, id: Uuid) -> Vec<u8> {
self.inner
.scrollback
.as_ref()
.map(|store| store.read(id, self.inner.replay_bytes))
.unwrap_or_default()
}
/// Mirror every live bridge's scrollback to disk (#171).
///
/// Called on a timer by the liveness watcher rather than only on a clean
/// exit, because the case this exists for — a host reboot — runs no exit
/// path at all: anything flushed only on shutdown is missing exactly when
/// it is needed. Sessions whose bridge has read nothing since the last
/// flush are skipped, so an idle host writes nothing.
///
/// Best-effort throughout: a failed write is warned and the session keeps
/// running.
pub async fn flush_scrollback(&self) {
run_scrollback_pass(&self.inner).await;
}
/// 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
@@ -856,6 +935,10 @@ impl SessionManager {
// Start "due" so the first live tick probes: a recovered daemon
// should not wait a full interval to show what blocks.
let mut since_probe = blocked_work_interval;
// Scrollback starts *not* due: a daemon that just recovered has
// nothing new to mirror, and the sessions it adopted were primed
// from the files this would overwrite (#171).
let mut since_flush = Duration::ZERO;
loop {
ticker.tick().await;
let Some(inner) = weak.upgrade() else {
@@ -870,6 +953,11 @@ impl SessionManager {
since_probe = Duration::ZERO;
run_blocked_work_pass(&inner).await;
}
since_flush += interval;
if since_flush >= DEFAULT_SCROLLBACK_INTERVAL {
since_flush = Duration::ZERO;
run_scrollback_pass(&inner).await;
}
}
});
}
@@ -929,8 +1017,15 @@ impl SessionManager {
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
// The ring the previous daemon held died with it, so an adopted
// session would otherwise replay nothing until tmux's own screen
// capture — which is one screenful, not scrollback (#171).
let bridge = PtyBridge::start_restoring(
fifo_path.clone(),
self.inner.replay_bytes,
self.restored_scrollback(id),
)
.await?;
// Re-pipe so output starts flowing again. `Tmux::pipe_pane` runs
// without tmux's `-o` flag, so any stale pipe the previous daemon
// left running to a now-unread FIFO is replaced (NAP-228).
@@ -958,6 +1053,7 @@ impl SessionManager {
focused_since: None,
awaiting_suppressed: false,
resuming: false,
flushed_bytes: 0,
},
);
let _ = self
@@ -1049,6 +1145,7 @@ impl SessionManager {
if let Err(e) = self.inner.sidecar.delete(session.id) {
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd");
}
self.prune_scrollback(session.id);
pruned += 1;
continue;
}
@@ -1059,6 +1156,7 @@ impl SessionManager {
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");
}
self.prune_scrollback(session.id);
discarded += 1;
continue;
}
@@ -1085,6 +1183,7 @@ impl SessionManager {
focused_since: None,
awaiting_suppressed: false,
resuming: false,
flushed_bytes: 0,
},
);
registered.push(session);
@@ -1724,6 +1823,36 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
}
}
/// Mirror every live bridge's scrollback to disk. See
/// [`SessionManager::flush_scrollback`].
async fn run_scrollback_pass(inner: &Arc<Inner>) {
let Some(store) = inner.scrollback.clone() else {
return;
};
// Snapshot under the lock, write with it released: the writes are
// filesystem work and every attach needs this map.
let pending: Vec<(Uuid, Vec<u8>)> = {
let mut sessions = inner.sessions.lock().await;
sessions
.iter_mut()
.filter_map(|(id, rec)| {
let bridge = rec.bridge.as_ref()?;
let seen = bridge.bytes_seen();
if seen == rec.flushed_bytes {
return None;
}
rec.flushed_bytes = seen;
Some((*id, bridge.scrollback()))
})
.collect()
};
for (id, bytes) in pending {
if let Err(e) = store.write(id, &bytes) {
tracing::warn!(error = ?e, %id, "scrollback: flush failed");
}
}
}
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and settle its worktree per the natural-exit rule (#82).
///
@@ -1750,6 +1879,15 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
// attach.
if let Some(bridge) = record.bridge.as_mut() {
bridge.seal();
// The clean-exit half of #171: the last thing the session
// printed is what a later resume should open on, and it
// will produce nothing more for the flush pass to notice.
if let Some(store) = &inner.scrollback {
if let Err(e) = store.write(id, &bridge.scrollback()) {
tracing::warn!(error = ?e, %id, "scrollback: final flush failed");
}
record.flushed_bytes = bridge.bytes_seen();
}
}
let session = record.session.clone();
let worktree = record
@@ -1871,6 +2009,14 @@ async fn cleanup_record(
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
}
// #171: the session is gone, so its scrollback goes with it — nothing
// will ever read the file again, and leaving it would keep a killed
// session's output on disk indefinitely.
if let Some(store) = &inner.scrollback {
if let Err(e) = store.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete scrollback");
}
}
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
if keep_worktree {
tracing::info!(
@@ -14,7 +14,7 @@ use std::process::Command;
use std::time::Duration;
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
@@ -91,13 +91,12 @@ async fn a_session_without_a_worktree_costs_no_probe() {
Duration::from_millis(100),
);
// The pass demonstrably runs and probed A's tree...
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.snapshot()
.await
.iter()
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
// 30s is a hang guard only, not the property under test — that's the
// timing-free `contains` check below.
wait_for_async(Duration::from_secs(30), || async {
std::fs::read_to_string(&log_path)
.map(|calls| calls.contains(repo_a.path().to_str().unwrap()))
.unwrap_or(false)
})
.await;
+21 -4
View File
@@ -151,27 +151,40 @@ pub struct DaemonFixture {
impl DaemonFixture {
pub fn new() -> Self {
Self::build(None)
Self::build(None, maestro_daemon::pty::DEFAULT_REPLAY_BYTES)
}
/// Variant whose manager runs a custom PTY replay budget (NAP-229), which
/// also caps the scrollback it persists (#171).
pub fn new_with_replay(replay_bytes: usize) -> Self {
Self::build(None, replay_bytes)
}
/// Variant whose manager has a durable spawn history (#24) attached, with
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
pub fn new_with_history(retention_days: u32) -> Self {
Self::build(Some(retention_days))
Self::build(
Some(retention_days),
maestro_daemon::pty::DEFAULT_REPLAY_BYTES,
)
}
fn build(history_days: Option<u32>) -> Self {
fn build(history_days: Option<u32>, replay_bytes: usize) -> Self {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
// Default placement: worktrees land under their base repo (#56).
let worktree_mgr = WorktreeManager::new(None);
let mut manager = SessionManager::new(
let mut manager = SessionManager::new_with_replay(
Arc::new(tmux.tmux.clone()),
sidecar,
worktree_mgr,
state_dir.path().join("fifos"),
replay_bytes,
);
// #171: production wires this, so the fixtures do too — every test
// then exercises the same flush/restore/delete paths the daemon runs.
manager = manager.with_scrollback(state_dir.path().join("scrollback"));
if let Some(days) = history_days {
manager =
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
@@ -198,4 +211,8 @@ impl DaemonFixture {
pub fn fifo_dir(&self) -> PathBuf {
self.state_dir.path().join("fifos")
}
pub fn scrollback_dir(&self) -> PathBuf {
self.state_dir.path().join("scrollback")
}
}
+219
View File
@@ -0,0 +1,219 @@
//! Persisted PTY scrollback across a reboot (#171), against real tmux.
//!
//! Everything else in the reboot milestone restores what a session *is*; this
//! restores what it *looked like*. The cases that matter are the ones a clean
//! shutdown never gets to run: the host went down, and the only scrollback
//! that exists is whatever the last periodic flush wrote.
mod common;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use common::{wait_for_async, DaemonFixture};
use maestro_daemon::scrollback::ScrollbackStore;
use maestro_daemon::SessionManager;
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus};
use tempfile::TempDir;
use uuid::Uuid;
/// A fresh manager over the same tmux server, sidecars, fifos and scrollback —
/// what the daemon builds after a restart.
fn revive(fx: &DaemonFixture) -> 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_scrollback(fx.scrollback_dir())
}
async fn spawn(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")
}
/// Wait until the session has printed `needle`, flushing as we go — the same
/// thing the liveness watcher's flush pass does on its timer.
async fn wait_for_flushed(
manager: &SessionManager,
store: &ScrollbackStore,
id: Uuid,
needle: &str,
) {
wait_for_async(Duration::from_secs(10), || async {
manager.flush_scrollback().await;
String::from_utf8_lossy(&store.read(id, 64 * 1024)).contains(needle)
})
.await;
}
fn replay_text(replay: &[u8]) -> String {
String::from_utf8_lossy(replay).to_string()
}
/// The keystone: the host reboots with no exit path run at all, and the
/// resumed session opens showing what it printed before.
#[tokio::test]
async fn a_resumed_session_replays_the_scrollback_from_before_the_reboot() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo before-the-reboot; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "before-the-reboot").await;
// The reboot: tmux dies where it stands. No kill, no seal, no shutdown
// hook — only whatever the periodic flush had already written.
fx.tmux.tmux.kill_server().expect("kill tmux server");
let manager = revive(&fx);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Dormant)
);
manager.resume(session.id, &[]).await.expect("resume");
let (replay, _rx) = manager
.subscribe_pty_with_replay(session.id)
.await
.expect("attachable again");
assert!(
replay_text(&replay).contains("before-the-reboot"),
"resumed pane should open on the old scrollback, got {:?}",
replay_text(&replay)
);
}
/// A daemon restart leaves tmux alone, so the session is adopted rather than
/// resumed — and it too lost its in-memory ring with the old process.
#[tokio::test]
async fn an_adopted_session_replays_the_scrollback_of_the_previous_daemon() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo printed-earlier; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "printed-earlier").await;
// Daemon restart: the manager goes, tmux stays.
let manager = revive(&fx);
manager.recover().await.expect("recover");
assert_eq!(
manager.status(session.id).await,
Some(SessionStatus::Running)
);
let (replay, _rx) = manager
.subscribe_pty_with_replay(session.id)
.await
.expect("attachable");
assert!(
replay_text(&replay).contains("printed-earlier"),
"adopted pane should carry the old scrollback ahead of tmux's screen, got {:?}",
replay_text(&replay)
);
}
/// Killing a session takes its scrollback with it: nothing will ever read the
/// file again, and the operator's output should not linger on disk.
#[tokio::test]
async fn killing_a_session_deletes_its_scrollback() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo doomed; sleep 30'").await;
wait_for_flushed(&fx.manager, &store, session.id, "doomed").await;
assert!(store.path_for(session.id).exists());
fx.manager.kill(session.id, false).await.expect("kill");
assert!(
!store.path_for(session.id).exists(),
"scrollback should not outlive the session"
);
}
/// A session that exits on its own does get an exit path, and takes it: the
/// last thing it printed is flushed when the bridge is sealed, with no wait
/// for the next tick.
#[tokio::test]
async fn a_session_that_exits_flushes_its_last_output() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(&fx, &cwd, "sh -c 'echo last-words'").await;
fx.manager
.start_liveness_watcher(Duration::from_millis(100));
wait_for_async(Duration::from_secs(10), || async {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(
String::from_utf8_lossy(&store.read(session.id, 64 * 1024)).contains("last-words"),
"a sealed bridge should have flushed what it printed"
);
}
/// Nothing here may fail a resume. A scrollback file that is gone, or one full
/// of bytes no terminal would emit, degrades to a pane with no history — not
/// to an error.
#[tokio::test]
async fn a_missing_or_corrupt_scrollback_still_resumes() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let gone = spawn(&fx, &cwd, "sleep 30").await;
let garbled = spawn(&fx, &cwd, "sleep 30").await;
fx.manager.flush_scrollback().await;
fx.tmux.tmux.kill_server().expect("kill tmux server");
std::fs::create_dir_all(store.dir()).expect("scrollback dir");
std::fs::remove_file(store.path_for(gone.id)).ok();
std::fs::write(store.path_for(garbled.id), [0xff, 0xfe, 0x00, 0x80]).expect("write garbage");
let manager = revive(&fx);
manager.recover().await.expect("recover");
for id in [gone.id, garbled.id] {
manager.resume(id, &[]).await.expect("resume");
assert_eq!(manager.status(id).await, Some(SessionStatus::Running));
assert!(manager.subscribe_pty_with_replay(id).await.is_some());
}
}
/// The disk cost is the replay budget, not a second one of its own.
#[tokio::test]
async fn the_flushed_file_stays_inside_the_replay_budget() {
let fx = DaemonFixture::new_with_replay(256);
let cwd = TempDir::new().unwrap();
let store = ScrollbackStore::new(fx.scrollback_dir());
let session = spawn(
&fx,
&cwd,
"sh -c 'for i in $(seq 1 400); do echo line-$i; done; sleep 30'",
)
.await;
wait_for_flushed(&fx.manager, &store, session.id, "line-400").await;
let size = std::fs::metadata(store.path_for(session.id))
.expect("stat scrollback")
.len();
assert!(
size <= 256,
"scrollback file was {size} bytes, budget is 256"
);
}
+6
View File
@@ -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.
+26
View File
@@ -108,6 +108,32 @@ dormant ghost on the next recovery. The other thing to do with a dormant
session is dismiss it, which removes the sidecar so the next recovery does not
bring it back.
## What the pane looks like when it comes back
The PTY replay ring lives in memory, so a resumed session would otherwise open
on a blank pane: the conversation is back, the terminal history the operator
was reading is not. Each bridge's ring is mirrored to
`<state_dir>/scrollback/<id>.bin` (#171) and handed to the new bridge on
resume, and on the recovery pass that re-adopts a session tmux still holds —
that one lost its ring with the previous daemon too. The restored bytes go out
ahead of the live ones, and are dropped once the ring holds a full budget of
new output of its own, which is the point where everything restored is older
than the budget covers.
The file is capped at the same `[daemon] pty_replay_bytes` budget the ring
uses — one budget, not two — and it is deleted when the session is killed, and
when recovery prunes its sidecar. Nothing here can fail a resume: a missing,
unreadable or oversized file degrades to an empty pane, which is exactly what
the operator had before it existed.
It is flushed every 10 seconds by the liveness watcher, and once more when a
session's bridge is sealed on exit. The timer is the part that matters: a host
reboot runs no exit path at all, so scrollback written only on a clean shutdown
would be missing in the one case this exists for. Ten seconds bounds the loss
to the last few lines — scrollback is visual continuity, not state — and costs
at most one file write per *active* session per tick, since a session whose
bridge has read nothing since the last flush is skipped.
## What recovery does with them: `resume_on_boot`
Whether a recovered-but-dead session is left dormant at all is the operator's
+27
View File
@@ -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:
+12
View File
@@ -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
View File
@@ -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);
};
+38
View File
@@ -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
View File
@@ -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");
+9 -2
View File
@@ -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
View File
@@ -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
+98 -1
View File
@@ -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
View File
@@ -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);
+7
View File
@@ -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", () => {
+8
View File
@@ -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";
}
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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", () => {
+8
View File
@@ -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);