Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a47f6fbf6 | ||
|
|
e7830d15b0 | ||
|
|
0fd107be77 | ||
|
|
915e408c16 | ||
|
|
577434dc43 | ||
|
|
cc1e7b5f98 | ||
|
|
c29d6f4339 | ||
|
|
938dd62648 | ||
|
|
8269bcabcc |
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,6 +113,33 @@ pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
||||
observed ending — so the two fragments never both show. The TUI marks the same
|
||||
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||
|
||||
### Dormant cards (#168)
|
||||
|
||||
A dormant card is a muted variant of the normal card (the web client's
|
||||
`is-dormant` class, the same muted foreground `exited` already uses): name,
|
||||
cwd and last-known age render as usual, but the activity rail carries no
|
||||
colour, since recovery resets a dormant session's activity to `Idle` and
|
||||
there is nothing running to report otherwise.
|
||||
|
||||
The row's right-hand actions swap **spawn similar** for **resume** (↻) — there
|
||||
is no live state to clone from — and call `POST /api/sessions/:id/resume`
|
||||
(#166). **Kill** stays: it is how a dormant session is dismissed, since the
|
||||
daemon already treats a kill of a dormant session as removing its sidecar
|
||||
rather than anything it needs to be running to do.
|
||||
|
||||
A dormant session takes the **lowest precedence in the ordering**: within a
|
||||
folder, every live (`Running` or `Dead`) session sorts before every dormant
|
||||
one, name notwithstanding, and dormant sessions sort among themselves by name.
|
||||
So a reboot that leaves a whole tree dormant never buries the live work a
|
||||
session spawns into it.
|
||||
|
||||
Selecting a dormant session — by click, by keyboard, or by preview
|
||||
(`docs/PREVIEW.md`) — never opens a live attach: the content pane shows the
|
||||
same metadata and resume affordance the card does, rather than a terminal
|
||||
that would die on the daemon's `409 not_attachable` (WS close 1006). Resuming
|
||||
turns the session `Running`; the pane and the card pick that up on their own
|
||||
next update, no navigation required.
|
||||
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||
while something would block an unforced kill, and reads:
|
||||
|
||||
@@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Respawn a dormant session under its own uuid and sidecar (#166/#168).
|
||||
* Refused with `409` (`already_running` / `cwd_missing`) when there is
|
||||
* nothing a respawn could fix. */
|
||||
export async function resumeSession(id: Uuid): Promise<Session> {
|
||||
return jsonOrError(
|
||||
await fetch(routes.sessionResume(id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Report whether this client's pane is the one currently showing `id` — not
|
||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||
|
||||
+22
-3
@@ -65,9 +65,17 @@ export interface AttachOptions {
|
||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||
onClosed?: (sessionId: Uuid) => void;
|
||||
/** Invoked when the handshake itself never completed — no retry was
|
||||
* attempted (see `shouldReconnect`'s `everOpened`). Unlike `onClosed`, this
|
||||
* is not "the session is gone": it is at least as likely a dormant
|
||||
* session's `409 not_attachable` caught mid-status-update (#168 review).
|
||||
* The shell re-renders rather than tearing the pane's leaf out, so a
|
||||
* dormant session's resume affordance can take over in place. */
|
||||
onNeverAttached?: (sessionId: Uuid) => void;
|
||||
/** Asked on every close: should the socket be retried? The shell answers
|
||||
* from the catalog — see `shouldRetryClose`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
||||
* from the catalog — see `shouldRetryClose`. `everOpened` is false when
|
||||
* this attempt never reached `open`. */
|
||||
shouldReconnect?: (sessionId: Uuid, code: number, everOpened: boolean) => boolean;
|
||||
}
|
||||
|
||||
/** What the header shows about the session (name / command). Optional — falls
|
||||
@@ -379,6 +387,10 @@ export function createAttachConnection(
|
||||
let ws: WebSocket;
|
||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||
let reconnectTimer: number | null = null;
|
||||
// #168 review: a fresh attempt that never reaches `open` is a rejected
|
||||
// handshake (dormant/unknown session), not a dropped stream — see
|
||||
// `shouldRetryClose`'s `everOpened` param.
|
||||
let everOpened = false;
|
||||
|
||||
const sendControl = (msg: ClientControl) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
@@ -402,6 +414,7 @@ export function createAttachConnection(
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.onopen = () => {
|
||||
everOpened = true;
|
||||
reconnectMs = INITIAL_RECONNECT_MS;
|
||||
setState("connected", "open");
|
||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||
@@ -444,7 +457,7 @@ export function createAttachConnection(
|
||||
// close handle their own pane teardown.
|
||||
if (disposed) return;
|
||||
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
||||
if (opts.shouldReconnect?.(sessionId, ev.code, everOpened)) {
|
||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||
setState("reconnecting…", "connecting");
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
@@ -455,6 +468,12 @@ export function createAttachConnection(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!everOpened) {
|
||||
setState("not attached", "closed");
|
||||
opts.onNeverAttached?.(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(`closed (${ev.code})`, "closed");
|
||||
opts.onClosed?.(sessionId);
|
||||
};
|
||||
|
||||
@@ -427,6 +427,44 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.textContent).not.toContain("exited");
|
||||
});
|
||||
|
||||
// #168: a dormant card is a muted variant with a resume action in place of
|
||||
// "spawn similar", since there is nothing running to clone from live state.
|
||||
it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => {
|
||||
const calls: string[] = [];
|
||||
window.fetch = ((url: string, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
expect(row.classList.contains("is-dormant")).toBe(true);
|
||||
expect(row.querySelector(".leaf-clone")).toBeNull();
|
||||
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||
expect(resumeBtn).toBeTruthy();
|
||||
resumeBtn?.click();
|
||||
await flush();
|
||||
expect(calls).toContain(`POST /api/sessions/${s.id}/resume`);
|
||||
});
|
||||
|
||||
// #168: a two-way running/dead test made a dormant card lie to screen
|
||||
// readers — the visible flag says dormant, the aria-label said "exited".
|
||||
it("marks a dormant row's status and aria-label distinctly from dead", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000119",
|
||||
status: "Dormant",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
expect(row.dataset.status).toBe("dormant");
|
||||
expect(row.getAttribute("aria-label")).toContain("dormant");
|
||||
expect(row.getAttribute("aria-label")).not.toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
|
||||
+53
-19
@@ -1,5 +1,5 @@
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js";
|
||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||
import { openContextMenu } from "./contextMenu.js";
|
||||
import { toggleHelp } from "./help.js";
|
||||
@@ -104,6 +104,21 @@ const STATE_WORDS: Record<string, string> = {
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and
|
||||
// the aria-label read the session's own three-way `status` rather than a
|
||||
// two-way running/dead test that folded `Dormant` into "dead"/"exited".
|
||||
function statusSlug(status: Session["status"]): string {
|
||||
if (status === "Running") return "running";
|
||||
if (status === "Dormant") return "dormant";
|
||||
return "dead";
|
||||
}
|
||||
|
||||
function statusWord(status: Session["status"]): string {
|
||||
if (status === "Running") return "running";
|
||||
if (status === "Dormant") return "dormant";
|
||||
return "exited";
|
||||
}
|
||||
|
||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||
* nesting depth, carrying the rail with it. */
|
||||
@@ -203,33 +218,46 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
row.dataset.navKey = `session:${s.id}`;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
row.dataset.status = statusSlug(s.status);
|
||||
// docs/SIDEBAR.md: a dormant card is a muted variant — nothing running
|
||||
// behind it, so it earns no activity colour or spawn-similar affordance.
|
||||
const dormant = s.status === "Dormant";
|
||||
row.classList.toggle("is-dormant", dormant);
|
||||
const name = sessionName(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
|
||||
const cloneOrResumeAction = dormant
|
||||
? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
|
||||
: `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
|
||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||
row.innerHTML = `
|
||||
${activityRailMarkup(slug, stateWord)}
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
${cloneOrResumeAction}
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${metaLine(s, now)}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
|
||||
similarBtn?.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||
});
|
||||
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||
resumeBtn?.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
resumeBtn.disabled = true;
|
||||
void resumeSession(cur.id).catch((e: Error) => {
|
||||
resumeBtn.disabled = false;
|
||||
alert(`Failed to resume: ${e.message}`);
|
||||
});
|
||||
});
|
||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||
killBtn.addEventListener("click", (ev) => {
|
||||
ev.preventDefault();
|
||||
@@ -244,11 +272,19 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
ev.stopPropagation();
|
||||
openContextMenu(ev.clientX, ev.clientY, [
|
||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||
{
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
cur.status === "Dormant"
|
||||
? {
|
||||
label: "Resume",
|
||||
onSelect: () =>
|
||||
void resumeSession(cur.id).catch((e: Error) =>
|
||||
alert(`Failed to resume: ${e.message}`),
|
||||
),
|
||||
}
|
||||
: {
|
||||
label: "Spawn similar",
|
||||
onSelect: () =>
|
||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||
},
|
||||
{
|
||||
label: "Kill session",
|
||||
danger: true,
|
||||
@@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
cur = next;
|
||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
||||
);
|
||||
row.dataset.status = statusSlug(next.status);
|
||||
row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
|
||||
const rail = row.querySelector(".activity-rail");
|
||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||
const meta = row.querySelector(".card-line2");
|
||||
|
||||
@@ -25,8 +25,13 @@ export interface PoolOptions {
|
||||
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
||||
* not via an intentional close/eviction. The embedder closes the pane. */
|
||||
onClosed?: (id: Uuid) => void;
|
||||
/** Fired when a connection's handshake never completed and so was never
|
||||
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
|
||||
* embedder should not assume the session is gone — a dormant session's
|
||||
* pane wants its resume affordance, not teardown. */
|
||||
onNeverAttached?: (id: Uuid) => void;
|
||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
||||
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +90,9 @@ export class ConnectionPool {
|
||||
{
|
||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
||||
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
|
||||
shouldReconnect: (sid, code, everOpened) =>
|
||||
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
|
||||
},
|
||||
info,
|
||||
);
|
||||
|
||||
+29
-1
@@ -19,6 +19,7 @@ import {
|
||||
getDaemonInfo,
|
||||
gitRoot,
|
||||
projects as fetchProjects,
|
||||
resumeSession,
|
||||
} from "./api.js";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
@@ -172,17 +173,42 @@ const pool = new ConnectionPool({
|
||||
// pane is never left lying around.
|
||||
onRequestClose: (id) => closePane(id),
|
||||
onClosed: (id) => closePane(id),
|
||||
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
||||
// #168 review: a handshake that never opened (a dormant session's `409
|
||||
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
|
||||
// connection and re-render so the leaf gets a fresh attempt if the catalog
|
||||
// still says Running, or the resume affordance if it doesn't.
|
||||
onNeverAttached: (id) => {
|
||||
pool.close(id);
|
||||
mv?.render();
|
||||
},
|
||||
shouldReconnect: (id, code, everOpened) =>
|
||||
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
|
||||
});
|
||||
|
||||
// Session death used to reach the UI on two uncoordinated channels — the row
|
||||
// from the events socket, the pane from `ws.onclose` — so either could happen
|
||||
// without the other. The catalog is the authority: a pane for a session that is
|
||||
// no longer listed gets closed here.
|
||||
//
|
||||
// A pane can also be showing the dormant placeholder (or, briefly, a dead
|
||||
// socket) rather than a live attach (#168): when an *open* session's
|
||||
// Running-ness flips — most commonly a resume completing — the pane and the
|
||||
// placeholder need to swap without waiting on an unrelated layout change.
|
||||
let lastOpenRunning = new Map<string, boolean>();
|
||||
store.subscribe((state) => {
|
||||
for (const id of mv?.openSessions() ?? []) {
|
||||
if (!state.sessions.has(id)) closePane(id);
|
||||
}
|
||||
const open = mv?.openSessions() ?? new Set<string>();
|
||||
const nextRunning = new Map<string, boolean>();
|
||||
let runningChanged = false;
|
||||
for (const id of open) {
|
||||
const running = state.sessions.get(id)?.status === "Running";
|
||||
nextRunning.set(id, running);
|
||||
if (lastOpenRunning.get(id) !== running) runningChanged = true;
|
||||
}
|
||||
lastOpenRunning = nextRunning;
|
||||
if (runningChanged) mv?.render();
|
||||
});
|
||||
|
||||
function closePane(id: string): void {
|
||||
@@ -275,6 +301,8 @@ mv = createMultiView({
|
||||
// catalog (no sessions) to attach.
|
||||
onOpenSessionsChange: updateShellRoute,
|
||||
onLayoutChange: scheduleSave,
|
||||
sessionFor: (id) => store.snapshot().sessions.get(id),
|
||||
onResume: (id) => resumeSession(id),
|
||||
});
|
||||
let contentShowsMv = false;
|
||||
/** Put the multi-view in the content pane, without rendering it. A preview
|
||||
|
||||
@@ -45,7 +45,7 @@ function fakeSidebar(): SidebarHandle {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMv(layout: Layout): MultiView {
|
||||
function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMultiView>[0]> = {}): MultiView {
|
||||
return createMultiView({
|
||||
pool: fakePool(),
|
||||
layout,
|
||||
@@ -53,6 +53,7 @@ function makeMv(layout: Layout): MultiView {
|
||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,6 +88,102 @@ describe("multiview empty panes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #168 review: highlighting a dormant session used to open a pane and attempt
|
||||
// a live attach, which the daemon answers with 404/409 and the socket dies
|
||||
// with WS 1006. A dormant leaf must never reach the pool at all — it gets the
|
||||
// resume affordance instead.
|
||||
describe("multiview dormant gating (#168)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function dormantSession(id: string) {
|
||||
return {
|
||||
id,
|
||||
cwd: "/home/n/work",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: { name: "worker" },
|
||||
status: "Dormant" as const,
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
it("never hands a dormant session's leaf to the pool, and shows a resume affordance", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||
const setVisible = vi.fn();
|
||||
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||
const mv = createMultiView({
|
||||
pool,
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
expect(setVisible).toHaveBeenCalledWith([], expect.anything());
|
||||
const pane = mv.el.querySelector('[data-leaf-id]') as HTMLElement;
|
||||
expect(pane.querySelector(".mv-pane-dormant")).toBeTruthy();
|
||||
expect(pane.textContent).toContain("worker");
|
||||
expect(pane.textContent).toContain("/home/n/work");
|
||||
expect(pane.querySelector(".mv-dormant-resume")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still attaches a Running session's leaf normally", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "r1", "row", true);
|
||||
const setVisible = vi.fn();
|
||||
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||
const mv = createMultiView({
|
||||
pool,
|
||||
layout,
|
||||
sidebar: fakeSidebar(),
|
||||
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||
onDisconnect: () => {},
|
||||
renderWelcome: () => document.createElement("div"),
|
||||
sessionFor: (id) => ({ ...dormantSession(id), status: "Running" }),
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
expect(setVisible).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: "r1" })],
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("clicking resume calls back with the session id", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||
const onResume = vi.fn().mockResolvedValue(undefined);
|
||||
const mv = makeMv(layout, {
|
||||
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||
onResume,
|
||||
});
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
mv.el.querySelector<HTMLButtonElement>(".mv-dormant-resume")?.click();
|
||||
expect(onResume).toHaveBeenCalledWith("d1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
|
||||
+61
-2
@@ -6,6 +6,10 @@
|
||||
|
||||
import type { SidebarHandle } from "./catalog.js";
|
||||
import type { ConnectionPool } from "./connections.js";
|
||||
import { activityDurationLabel, activityHeldMs } from "./activity.js";
|
||||
import { escapeHtml } from "./html.js";
|
||||
import { sessionName } from "./types.js";
|
||||
import type { Session } from "./types.js";
|
||||
import {
|
||||
activeTab,
|
||||
addTab,
|
||||
@@ -34,6 +38,14 @@ export interface MultiViewOptions {
|
||||
onDisconnect: (sessionId: string) => void;
|
||||
/** Welcome content for an empty tab. */
|
||||
renderWelcome: () => HTMLElement;
|
||||
/** The session behind a leaf, when known — used to gate a live attach on
|
||||
* `status === "Running"` (#168 review) and to fill in the dormant pane's
|
||||
* resume affordance. A leaf whose session this returns `undefined` for
|
||||
* (unknown to the caller) attaches as before, so callers that don't wire
|
||||
* this up see no change in behaviour. */
|
||||
sessionFor?: (sessionId: string) => Session | undefined;
|
||||
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
|
||||
onResume?: (sessionId: string) => Promise<unknown>;
|
||||
/** Called when a spawn fills a pane with a new session, so the shell can
|
||||
* re-evaluate its route (sidebar vs attach layout). */
|
||||
onOpenSessionsChange?: () => void;
|
||||
@@ -262,6 +274,44 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
persist();
|
||||
}
|
||||
|
||||
// ---- dormant panes (#168 review) ----
|
||||
|
||||
/** The session behind `sessionId` when it is known and not `Running` —
|
||||
* what gates a leaf away from a live attach and towards the resume
|
||||
* affordance instead. */
|
||||
function dormantSessionFor(sessionId: string): Session | undefined {
|
||||
const s = opts.sessionFor?.(sessionId);
|
||||
return s && s.status !== "Running" ? s : undefined;
|
||||
}
|
||||
|
||||
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
|
||||
* show for a session with no pane behind it — its metadata and a resume
|
||||
* affordance, never a live terminal that would just die with WS 1006. */
|
||||
function renderDormantPane(host: HTMLElement, session: Session): void {
|
||||
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
|
||||
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "mv-pane-dormant";
|
||||
wrap.innerHTML = `
|
||||
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
|
||||
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
|
||||
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
|
||||
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
|
||||
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
|
||||
`;
|
||||
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
|
||||
btn.addEventListener("click", () => {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Resuming…";
|
||||
opts.onResume?.(session.id)?.catch((e: Error) => {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Resume";
|
||||
alert(`Failed to resume: ${e.message}`);
|
||||
});
|
||||
});
|
||||
host.replaceChildren(wrap);
|
||||
}
|
||||
|
||||
// ---- pane + split DOM ----
|
||||
|
||||
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
|
||||
@@ -378,7 +428,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (node.kind === "leaf") {
|
||||
const pane = buildPane(node);
|
||||
if (node.sessionId) {
|
||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||
const dormant = dormantSessionFor(node.sessionId);
|
||||
if (dormant) {
|
||||
renderDormantPane(pane.host, dormant);
|
||||
} else {
|
||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||
}
|
||||
}
|
||||
return pane.el;
|
||||
}
|
||||
@@ -616,9 +671,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
wrap.appendChild(pane.el);
|
||||
canvas.appendChild(wrap);
|
||||
|
||||
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
|
||||
if (dormant) renderDormantPane(pane.host, dormant);
|
||||
// Only the focused leaf's session is live on mobile; the rest park.
|
||||
pool.setVisible(
|
||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
||||
focused.sessionId && !dormant
|
||||
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
|
||||
: [],
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
|
||||
@@ -47,6 +47,13 @@ describe("shouldRetryClose", () => {
|
||||
it("never retries a clean close", () => {
|
||||
expect(shouldRetryClose("a", 1000, running)).toBe(false);
|
||||
});
|
||||
|
||||
// #168 review: a handshake that never opened is a rejection (e.g. a
|
||||
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
|
||||
// loops forever if the catalog status hasn't caught up yet.
|
||||
it("never retries a socket that never opened, even if the catalog still says running", () => {
|
||||
expect(shouldRetryClose("a", 1006, running, false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backoff bounds", () => {
|
||||
|
||||
@@ -26,7 +26,15 @@ export function shouldRetryClose(
|
||||
sessionId: Uuid,
|
||||
code: number,
|
||||
sessions: ReadonlyMap<Uuid, Session>,
|
||||
/** Did this socket ever reach `open`? A handshake that never completed is
|
||||
* the daemon rejecting the attach (dormant/unknown) rather than a dropped
|
||||
* stream — retrying it spins in place until the catalog updates, which for
|
||||
* a dormant session it never will (#168 review). Defaults to `true` so
|
||||
* existing callers that only track drops of an established link are
|
||||
* unaffected. */
|
||||
everOpened = true,
|
||||
): boolean {
|
||||
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
|
||||
if (!everOpened) return false;
|
||||
return sessions.get(sessionId)?.status === "Running";
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string {
|
||||
return `${session(id)}/focus`;
|
||||
}
|
||||
|
||||
/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session
|
||||
* under its own uuid and sidecar. */
|
||||
export function sessionResume(id: Uuid): string {
|
||||
return `${session(id)}/resume`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
+45
-4
@@ -1210,12 +1210,16 @@ textarea {
|
||||
}
|
||||
|
||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||
never the running tint's absence alone doing the work. */
|
||||
.tree-leaf[data-status="dead"] {
|
||||
never the running tint's absence alone doing the work. A dormant session
|
||||
(#168, docs/SIDEBAR.md) reads the same muted way — it is known, not gone,
|
||||
but there is nothing running behind it either. */
|
||||
.tree-leaf[data-status="dead"],
|
||||
.tree-leaf.is-dormant {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tree-leaf[data-status="dead"] .leaf-name {
|
||||
.tree-leaf[data-status="dead"] .leaf-name,
|
||||
.tree-leaf.is-dormant .leaf-name {
|
||||
color: var(--muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
@@ -1754,7 +1758,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
}
|
||||
|
||||
.leaf-clone:hover {
|
||||
.leaf-clone:hover,
|
||||
.leaf-resume:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||
@@ -3265,6 +3270,42 @@ input.leaf-rename:focus {
|
||||
font-size: var(--fs-13);
|
||||
}
|
||||
|
||||
/* #168: what a pane shows for a dormant session — its metadata and a resume
|
||||
action, in place of the live terminal there is nothing to attach to. */
|
||||
.mv-pane-dormant {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-4);
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mv-dormant-glyph {
|
||||
font-size: 28px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.mv-dormant-name {
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
.mv-dormant-cwd {
|
||||
font-size: var(--fs-12);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mv-dormant-detail {
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
.mv-dormant-resume {
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
|
||||
.mv-pane-tools {
|
||||
|
||||
+37
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
||||
import { buildTree, compareNodes, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
function session(id: string, cwd: string, command = "bash"): Session {
|
||||
@@ -67,6 +67,42 @@ describe("buildTree", () => {
|
||||
expect(root.children[0].kind).toBe("folder");
|
||||
expect(root.children[1].kind).toBe("leaf");
|
||||
});
|
||||
|
||||
// #168, docs/SIDEBAR.md: a dormant session takes the lowest precedence in
|
||||
// the ordering, so live work always sorts above it, even a name that would
|
||||
// otherwise sort first.
|
||||
it("sorts a dormant session after live ones in the same folder, name notwithstanding", () => {
|
||||
const dormant = { ...session("aaa-dormant", "/home/n/work"), status: "Dormant" } as Session;
|
||||
const live = session("zzz-live", "/home/n/work");
|
||||
const root = buildTree([dormant, live], "/home/n") as TreeFolder;
|
||||
const folder = root.children[0] as TreeFolder;
|
||||
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||
expect(ids).toEqual(["zzz-live", "aaa-dormant"]);
|
||||
});
|
||||
|
||||
it("still sorts dormant sessions by name against each other", () => {
|
||||
const a = {
|
||||
...session("a", "/home/n/work"),
|
||||
status: "Dormant",
|
||||
metadata: { name: "a" },
|
||||
} as Session;
|
||||
const b = {
|
||||
...session("b", "/home/n/work"),
|
||||
status: "Dormant",
|
||||
metadata: { name: "b" },
|
||||
} as Session;
|
||||
const root = buildTree([b, a], "/home/n") as TreeFolder;
|
||||
const folder = root.children[0] as TreeFolder;
|
||||
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||
expect(ids).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("exposes the leaf/name precedence directly via compareNodes", () => {
|
||||
const dormant: TreeNode = { kind: "leaf", session: { ...session("a", "/x"), status: "Dormant" } };
|
||||
const live: TreeNode = { kind: "leaf", session: session("b", "/x") };
|
||||
expect(compareNodes(dormant, live)).toBeGreaterThan(0);
|
||||
expect(compareNodes(live, dormant)).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeLinear", () => {
|
||||
|
||||
@@ -18,8 +18,16 @@ export interface TreeLeaf {
|
||||
|
||||
export type TreeNode = TreeFolder | TreeLeaf;
|
||||
|
||||
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
|
||||
* ordering, so live work always sorts above it regardless of name. */
|
||||
function dormantRank(node: TreeNode): number {
|
||||
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
|
||||
}
|
||||
|
||||
export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||
const rank = dormantRank(a) - dormantRank(b);
|
||||
if (rank !== 0) return rank;
|
||||
const an = a.kind === "folder" ? a.name : sessionName(a.session);
|
||||
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
|
||||
return an.localeCompare(bn);
|
||||
|
||||
Reference in New Issue
Block a user