Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a47f6fbf6 | ||
|
|
e7830d15b0 | ||
|
|
87f1e73dec |
@@ -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);
|
||||
@@ -1305,10 +1404,12 @@ impl SessionManager {
|
||||
/// and stays silent on the event stream at steady state.
|
||||
///
|
||||
/// `agent_session_id` (#161) is the agent's own conversation id, parsed
|
||||
/// by the caller from the hook payload's `session_id` field. It is
|
||||
/// written once — the first report that carries one wins — and the
|
||||
/// sidecar is only rewritten on that transition, never on every report,
|
||||
/// since an agent's session id never changes mid-session.
|
||||
/// by the caller from the hook payload's `session_id` field. The
|
||||
/// sidecar is rewritten only when the reported id differs from the
|
||||
/// stored one — a repeated report of the same id is pure churn from the
|
||||
/// hook's `PreToolUse` cadence and writes nothing, but a resume (#176)
|
||||
/// issues a new conversation id and must replace the stale one, or
|
||||
/// every later resume reattaches the pre-resume conversation.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
@@ -1330,16 +1431,16 @@ impl SessionManager {
|
||||
state,
|
||||
self.inner.report_ttl,
|
||||
));
|
||||
let newly_identified =
|
||||
rec.session.agent_session_id.is_none() && agent_session_id.is_some();
|
||||
if newly_identified {
|
||||
let id_changed =
|
||||
agent_session_id.is_some() && rec.session.agent_session_id != agent_session_id;
|
||||
if id_changed {
|
||||
rec.session.agent_session_id = agent_session_id;
|
||||
self.inner
|
||||
.sidecar
|
||||
.write(&rec.session)
|
||||
.map_err(SessionError::Sidecar)?;
|
||||
}
|
||||
(changed || newly_identified).then(|| rec.session.clone())
|
||||
(changed || id_changed).then(|| rec.session.clone())
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = self
|
||||
@@ -1722,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).
|
||||
///
|
||||
@@ -1748,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
|
||||
@@ -1869,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!(
|
||||
|
||||
@@ -187,10 +187,30 @@ async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
/// #161: a session id does not change mid-session, so once set it is never
|
||||
/// overwritten by a later report — even one that names a different id.
|
||||
/// #161: repeated reports of the same id are pure churn from the hook's
|
||||
/// `PreToolUse` cadence — the sidecar must not be rewritten for them.
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||
async fn a_repeated_report_of_the_same_session_id_writes_nothing() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Active, None, Some("conv-first".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
}
|
||||
|
||||
/// #176: a resume issues a new conversation id. The stored id must move to
|
||||
/// it, or every later resume reattaches the stale pre-resume conversation.
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_replaced_when_it_differs() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
@@ -204,7 +224,14 @@ async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-second"));
|
||||
|
||||
let stored = fx
|
||||
.sidecar
|
||||
.read(id)
|
||||
.expect("sidecar read")
|
||||
.expect("sidecar present");
|
||||
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-second"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -141,16 +141,6 @@ impl DaemonClient {
|
||||
json(check(resp).await?).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/resume` (#166): respawn a dormant session
|
||||
/// under its own uuid and sidecar. Refused with `409` (`already_running`
|
||||
/// / `cwd_missing`) when there is nothing a respawn could fix.
|
||||
pub async fn resume_session(&self, id: Uuid) -> Result<Session, ClientError> {
|
||||
let resp = self
|
||||
.send(Method::POST, &routes::session_resume(id), &[], None::<&()>)
|
||||
.await?;
|
||||
json(check(resp).await?).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||
/// state. The daemon holds it above its own inference for a TTL, then
|
||||
/// resumes inference. Answers `204 No Content`.
|
||||
|
||||
@@ -51,9 +51,6 @@ pub enum Action {
|
||||
/// Rename the highlighted session (#125): opens a prompt pre-filled with
|
||||
/// its current name.
|
||||
RenameHighlighted,
|
||||
/// Resume the highlighted dormant session (#166, #169): respawns it
|
||||
/// under its own uuid and sidecar via `POST /api/sessions/:id/resume`.
|
||||
ResumeHighlighted,
|
||||
|
||||
// Chrome
|
||||
/// Open the `ctrl-k` command bar (`docs/COMMAND-BAR.md`) — a query line
|
||||
@@ -108,7 +105,6 @@ impl Action {
|
||||
Action::FastForwardSpawn => "Create session with defaults".into(),
|
||||
Action::KillHighlighted => "Kill selected session".into(),
|
||||
Action::RenameHighlighted => "Rename selected session".into(),
|
||||
Action::ResumeHighlighted => "Resume dormant session".into(),
|
||||
Action::OpenCommandBar => "Go to a session".into(),
|
||||
Action::SendLiteralCtrlK => "Send literal Ctrl-K to session".into(),
|
||||
Action::SendLiteralLeader => "Send literal leader key to session".into(),
|
||||
@@ -138,7 +134,6 @@ impl Action {
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
|
||||
"ResumeHighlighted" => return Ok(Action::ResumeHighlighted),
|
||||
"OpenCommandBar" => return Ok(Action::OpenCommandBar),
|
||||
"SendLiteralCtrlK" => return Ok(Action::SendLiteralCtrlK),
|
||||
"SendLiteralLeader" => return Ok(Action::SendLiteralLeader),
|
||||
@@ -197,16 +192,6 @@ mod tests {
|
||||
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
|
||||
}
|
||||
|
||||
/// #169: resume is its own verb, distinct from rename/kill, so a dormant
|
||||
/// session's sidebar card has a keyboard-reachable resume.
|
||||
#[test]
|
||||
fn parses_resume_highlighted() {
|
||||
assert_eq!(
|
||||
Action::parse("ResumeHighlighted").unwrap(),
|
||||
Action::ResumeHighlighted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_labels_are_plain_language() {
|
||||
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
|
||||
|
||||
+20
-163
@@ -34,7 +34,7 @@ use crate::input::{
|
||||
use crate::keymap::Mode;
|
||||
use crate::overlays::HelpOverlay;
|
||||
use crate::pane::{Pane, PaneStatus};
|
||||
use crate::render::{draw, PanePlaceholder, RegionMap, RenderInputs};
|
||||
use crate::render::{draw, RegionMap, RenderInputs};
|
||||
use crate::session_task::{SessionInput, SessionTask};
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||
@@ -190,10 +190,6 @@ pub enum Effect {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
},
|
||||
/// Resume a dormant session (#166, #169): respawns it under its own
|
||||
/// uuid and sidecar. The catalog picks up the resulting `Running` status
|
||||
/// from the daemon's own `SessionUpdated` broadcast.
|
||||
Resume(Uuid),
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -267,11 +263,6 @@ impl App {
|
||||
}
|
||||
_ = self.catalog.changed() => {
|
||||
// Picked up by the frame key's catalog version.
|
||||
let effects = self.poll_resumed_attach();
|
||||
if !effects.is_empty() {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
}
|
||||
self.perform(effects).await;
|
||||
}
|
||||
_ = self.link.changed() => {
|
||||
// The status indicator and the restart overlay both read
|
||||
@@ -423,7 +414,6 @@ impl App {
|
||||
Effect::ProbeGitRoot { seq, path } => self.spawn_probe_git_root(seq, path),
|
||||
Effect::LoadBranches => self.spawn_load_branches(),
|
||||
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
|
||||
Effect::Resume(id) => self.submit_resume(id).await,
|
||||
Effect::Forward { id, bytes } => {
|
||||
if let Some(handle) = self.emulators.get(id) {
|
||||
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
|
||||
@@ -597,10 +587,7 @@ impl App {
|
||||
}
|
||||
// A previewed session is already attached: committing
|
||||
// to it keeps that stream rather than reopening one.
|
||||
// A session that isn't Running has no bridge to
|
||||
// attach to (`docs/PREVIEW.md`) — the pane falls back
|
||||
// to its dormant/exited placeholder instead.
|
||||
if self.emulators.get(sid).is_none() && self.session_running(sid) {
|
||||
if self.emulators.get(sid).is_none() {
|
||||
effects.push(Effect::Attach(sid));
|
||||
}
|
||||
}
|
||||
@@ -620,11 +607,6 @@ impl App {
|
||||
self.prompt_rename(sid);
|
||||
}
|
||||
}
|
||||
Action::ResumeHighlighted => {
|
||||
if let Some(sid) = self.pick_sidebar_target() {
|
||||
effects.push(Effect::Resume(sid));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every action can move the highlight or the focus, and the preview is
|
||||
// a function of both (`docs/PREVIEW.md`).
|
||||
@@ -681,41 +663,13 @@ impl App {
|
||||
self.state.preview = Some(sid);
|
||||
// Set outside the input path, so nothing else marks the frame stale.
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
// A session that isn't Running has no bridge to preview-attach to
|
||||
// (`docs/PREVIEW.md`): the pane falls back to its dormant/exited
|
||||
// placeholder, and the preview budget has nothing of its own to track.
|
||||
let attach = self.emulators.get(sid).is_none() && self.session_running(sid);
|
||||
if attach {
|
||||
let fresh = self.emulators.get(sid).is_none();
|
||||
if fresh {
|
||||
self.preview_lru.retain(|s| *s != sid);
|
||||
self.preview_lru.push(sid);
|
||||
}
|
||||
self.trim_preview_budget();
|
||||
if attach {
|
||||
vec![Effect::Attach(sid)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `sid` is currently `Running`, per the catalog — the only
|
||||
/// status a live pane attach is allowed for (`docs/PREVIEW.md`).
|
||||
fn session_running(&self, sid: Uuid) -> bool {
|
||||
self.catalog
|
||||
.snapshot()
|
||||
.sessions
|
||||
.iter()
|
||||
.any(|s| s.id == sid && s.status == maestro_protocol::SessionStatus::Running)
|
||||
}
|
||||
|
||||
/// A previewed or committed session gates its attach on `Running`
|
||||
/// (`docs/PREVIEW.md`) and may only become `Running` later — most
|
||||
/// commonly a resume completing. Catch that up on a catalog change
|
||||
/// rather than waiting on an unrelated highlight move.
|
||||
fn poll_resumed_attach(&mut self) -> Vec<Effect> {
|
||||
let Some(sid) = self.state.displayed_session() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.emulators.get(sid).is_none() && self.session_running(sid) {
|
||||
if fresh {
|
||||
vec![Effect::Attach(sid)]
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -1373,15 +1327,6 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume a dormant session (#166, #169). The catalog's own
|
||||
/// `SessionUpdated` broadcast carries the `Running` status back; nothing
|
||||
/// is patched locally here.
|
||||
async fn submit_resume(&mut self, sid: Uuid) {
|
||||
if let Err(e) = self.daemon.resume_session(sid).await {
|
||||
self.state.toast(format!("resume failed: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Report a pane-focus change to the daemon (docs/SIDEBAR.md's
|
||||
/// `AwaitingInput` auto-clear). Only `Focus::Pane` on the active tab's
|
||||
/// session counts — the sidebar highlight is never a "focused pane".
|
||||
@@ -1471,32 +1416,21 @@ impl App {
|
||||
focused: matches!(self.state.focus, Focus::Pane) && !previewing,
|
||||
})
|
||||
});
|
||||
let shown_session = shown_sid.and_then(|sid| snap.sessions.iter().find(|s| s.id == sid));
|
||||
let shown_name = shown_session
|
||||
.map(crate::sidebar::name_for)
|
||||
.unwrap_or_else(|| "session".into());
|
||||
let pane_title = if previewing && shown_sid.is_some() {
|
||||
format!("{shown_name} · preview")
|
||||
} else {
|
||||
shown_name.clone()
|
||||
};
|
||||
// docs/PREVIEW.md: a session that isn't Running never gets a live
|
||||
// attach, so an empty pane here means "nothing to attach to", not
|
||||
// merely "not yet connected" — the pane names it and offers resume
|
||||
// instead of a terminal that would die on the daemon's 409.
|
||||
let shown_cwd = shown_session
|
||||
.map(|s| s.cwd.display().to_string())
|
||||
.unwrap_or_default();
|
||||
let pane_placeholder = if pane.is_none() {
|
||||
shown_session
|
||||
.filter(|s| s.status != maestro_protocol::SessionStatus::Running)
|
||||
.map(|s| PanePlaceholder {
|
||||
name: &shown_name,
|
||||
cwd: &shown_cwd,
|
||||
dormant: s.status == maestro_protocol::SessionStatus::Dormant,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
let pane_title = match shown_sid {
|
||||
Some(sid) => {
|
||||
let name = snap
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == sid)
|
||||
.map(crate::sidebar::name_for)
|
||||
.unwrap_or_else(|| "session".into());
|
||||
if previewing {
|
||||
format!("{name} · preview")
|
||||
} else {
|
||||
name
|
||||
}
|
||||
}
|
||||
None => "session".into(),
|
||||
};
|
||||
|
||||
let link_status = self.link.status();
|
||||
@@ -1505,7 +1439,6 @@ impl App {
|
||||
home_dir: &self.home_dir,
|
||||
pane_title: &pane_title,
|
||||
pane: pane.as_ref(),
|
||||
pane_placeholder,
|
||||
state: &self.state,
|
||||
draft: self.draft.as_ref(),
|
||||
help: &self.help,
|
||||
@@ -2191,82 +2124,6 @@ mod tests {
|
||||
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||
}
|
||||
|
||||
/// #169, docs/PREVIEW.md: a session that isn't `Running` never gets a
|
||||
/// live attach — the daemon has nothing to answer a 409 with. Committing
|
||||
/// to it still moves the tab, so the pane can show its placeholder.
|
||||
#[test]
|
||||
fn attach_highlighted_never_attaches_a_dormant_session() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(11);
|
||||
let mut app = make_app();
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
assert_eq!(app.dispatch(Action::AttachHighlighted), Vec::new());
|
||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||
}
|
||||
|
||||
/// #169, docs/PREVIEW.md: moving the highlight across dormant sessions —
|
||||
/// the sharp case after a reboot — must never try a live preview attach.
|
||||
#[test]
|
||||
fn preview_never_attaches_a_dormant_session() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(12);
|
||||
let mut app = make_app();
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
app.dispatch(Action::SidebarNext);
|
||||
|
||||
assert_eq!(app.poll_due_preview_at(after_gap()), Vec::new());
|
||||
assert_eq!(app.state.preview, Some(sid));
|
||||
}
|
||||
|
||||
/// #169: a session that becomes `Running` while already shown — a resume
|
||||
/// completing, most commonly — attaches without waiting on an unrelated
|
||||
/// highlight move.
|
||||
#[test]
|
||||
fn a_shown_session_attaches_once_it_turns_running() {
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus};
|
||||
let sid = Uuid::from_u128(13);
|
||||
let mut app = make_app();
|
||||
let mut dormant = test_session(sid, "/repo");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
sessions: vec![dormant.clone()],
|
||||
});
|
||||
app.state.attach_to_focused(sid);
|
||||
assert_eq!(app.poll_resumed_attach(), Vec::new());
|
||||
|
||||
let mut running = dormant;
|
||||
running.status = SessionStatus::Running;
|
||||
app.catalog
|
||||
.apply_event(CatalogEvent::SessionUpdated { session: running });
|
||||
assert_eq!(app.poll_resumed_attach(), vec![Effect::Attach(sid)]);
|
||||
}
|
||||
|
||||
// -- resume (#166, #169) -------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resume_highlighted_asks_for_a_resume() {
|
||||
use maestro_protocol::SessionStatus;
|
||||
let mut app = make_app();
|
||||
let sid = Uuid::from_u128(14);
|
||||
let mut s = test_session(sid, "/repo");
|
||||
s.status = SessionStatus::Dormant;
|
||||
app.catalog
|
||||
.apply_event(maestro_protocol::CatalogEvent::Snapshot { sessions: vec![s] });
|
||||
|
||||
assert_eq!(
|
||||
app.dispatch(Action::ResumeHighlighted),
|
||||
vec![Effect::Resume(sid)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
|
||||
use maestro_protocol::CatalogEvent;
|
||||
|
||||
@@ -59,8 +59,6 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
("N", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
("r", "RenameHighlighted"),
|
||||
// Resume a dormant session (#166, #169).
|
||||
("u", "ResumeHighlighted"),
|
||||
// Focus
|
||||
("ctrl-w h", "FocusSidebar"),
|
||||
// Session scrollback
|
||||
@@ -259,10 +257,6 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "N"),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "u"),
|
||||
Some(Action::ResumeHighlighted),
|
||||
);
|
||||
}
|
||||
|
||||
/// AoE-style defaults (#shortcuts): `q` and `tab` are bare because Normal
|
||||
|
||||
@@ -72,18 +72,6 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
|
||||
}
|
||||
|
||||
/// What the pane shows in place of a live attach when the session behind it
|
||||
/// isn't `Running` (`docs/PREVIEW.md`): a session that is not `Running` never
|
||||
/// gets a live attach, since the daemon has no bridge to answer one.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PanePlaceholder<'a> {
|
||||
pub name: &'a str,
|
||||
pub cwd: &'a str,
|
||||
/// `true` for a dormant session (offers resume), `false` for a dead one
|
||||
/// (`docs/SIDEBAR.md`'s `exited`/`dormant` fragments never both show).
|
||||
pub dormant: bool,
|
||||
}
|
||||
|
||||
/// Bundle of references the renderer reads from. Built by `App::redraw` each
|
||||
/// frame.
|
||||
#[derive(Debug)]
|
||||
@@ -92,9 +80,6 @@ pub struct RenderInputs<'a> {
|
||||
pub home_dir: &'a str,
|
||||
pub pane_title: &'a str,
|
||||
pub pane: Option<&'a Pane>,
|
||||
/// What to show instead, when `pane` is `None` because the shown session
|
||||
/// isn't `Running` rather than merely not yet connected.
|
||||
pub pane_placeholder: Option<PanePlaceholder<'a>>,
|
||||
/// The spawn form behind [`OverlayKind::Spawn`], when one is up.
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
@@ -162,8 +147,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
|
||||
if let Some(p) = inputs.pane {
|
||||
p.render(f, pane_area);
|
||||
} else if let Some(ph) = inputs.pane_placeholder {
|
||||
render_pane_placeholder(f, pane_area, ph);
|
||||
}
|
||||
// `RegionMap::pane` is also the PTY resize source. The title bar is
|
||||
// chrome; every remaining cell belongs to the emulator.
|
||||
@@ -256,44 +239,6 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
map
|
||||
}
|
||||
|
||||
/// docs/PREVIEW.md / docs/SIDEBAR.md: what the pane can honestly show for a
|
||||
/// session with no bridge behind it — its metadata and a resume affordance
|
||||
/// for a dormant one, never a terminal that would die on the daemon's 409.
|
||||
fn render_pane_placeholder(f: &mut Frame, area: Rect, ph: PanePlaceholder<'_>) {
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let glyph = if ph.dormant { "◌" } else { "✗" };
|
||||
let status = if ph.dormant {
|
||||
"dormant — resume from the sidebar"
|
||||
} else {
|
||||
"exited"
|
||||
};
|
||||
let mid = area.y + area.height / 2;
|
||||
let lines = [
|
||||
(format!("{glyph} {}", ph.name), surface.fg),
|
||||
(ph.cwd.to_string(), surface.muted),
|
||||
(status.to_string(), surface.muted),
|
||||
];
|
||||
for (i, (text, colour)) in lines.iter().enumerate() {
|
||||
let Some(y) = mid.checked_sub(1).map(|y| y + i as u16) else {
|
||||
continue;
|
||||
};
|
||||
if y >= area.y + area.height {
|
||||
continue;
|
||||
}
|
||||
let x = area.x + (area.width.saturating_sub(text.chars().count() as u16)) / 2;
|
||||
f.render_widget(
|
||||
Paragraph::new(text.as_str()).style(Style::default().bg(surface.bg).fg(*colour)),
|
||||
Rect::new(
|
||||
x,
|
||||
y,
|
||||
text.chars().count().min(area.width as usize) as u16,
|
||||
1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
|
||||
let palette = palette::active();
|
||||
let style = if focused {
|
||||
@@ -490,7 +435,6 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -588,7 +532,6 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: Some(&draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -644,7 +587,6 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -687,7 +629,6 @@ mod tests {
|
||||
home_dir: "",
|
||||
pane_title: "session",
|
||||
pane: None,
|
||||
pane_placeholder: None,
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
@@ -715,52 +656,4 @@ mod tests {
|
||||
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
|
||||
assert!(title.contains("session"), "missing title: {title:?}");
|
||||
}
|
||||
|
||||
/// docs/PREVIEW.md: a session that isn't `Running` never gets a live
|
||||
/// attach — the pane names it and offers resume instead of a terminal
|
||||
/// that would die on the daemon's 409.
|
||||
#[test]
|
||||
fn a_dormant_pane_placeholder_names_the_session_and_offers_resume() {
|
||||
use crate::overlays::HelpOverlay;
|
||||
use std::collections::HashSet;
|
||||
|
||||
let sidebar = Sidebar {
|
||||
sessions: Vec::new(),
|
||||
highlight: None,
|
||||
attached: HashSet::new(),
|
||||
pane_session: None,
|
||||
};
|
||||
let help = HelpOverlay { lines: Vec::new() };
|
||||
let state = AppState::new();
|
||||
let inputs = RenderInputs {
|
||||
sidebar: &sidebar,
|
||||
home_dir: "",
|
||||
pane_title: "worker",
|
||||
pane: None,
|
||||
pane_placeholder: Some(PanePlaceholder {
|
||||
name: "worker",
|
||||
cwd: "/home/n/work",
|
||||
dormant: true,
|
||||
}),
|
||||
draft: None,
|
||||
state: &state,
|
||||
help: &help,
|
||||
rename_input: &crate::text_input::TextInput::default(),
|
||||
command_bar: None,
|
||||
link: &online_link(),
|
||||
};
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
draw(f, inputs);
|
||||
})
|
||||
.unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let text: String = buf.content().iter().map(|c| c.symbol()).collect();
|
||||
assert!(text.contains("worker"), "{text}");
|
||||
assert!(text.contains("/home/n/work"), "{text}");
|
||||
assert!(text.contains("dormant"), "{text}");
|
||||
assert!(text.contains("resume"), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,16 +184,8 @@ impl Sidebar {
|
||||
grouped
|
||||
.into_iter()
|
||||
.map(|(key, mut sessions)| {
|
||||
// docs/SIDEBAR.md, "Dormant cards": a dormant session takes
|
||||
// the lowest precedence, name notwithstanding, so a reboot
|
||||
// that leaves a tree dormant never buries live work spawned
|
||||
// into it.
|
||||
sessions.sort_by(|a, b| {
|
||||
dormant_rank(a)
|
||||
.cmp(&dormant_rank(b))
|
||||
.then_with(|| name_for(a).cmp(&name_for(b)))
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
sessions
|
||||
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
||||
SidebarGroup {
|
||||
collapsed: collapsed.contains(&key),
|
||||
key,
|
||||
@@ -284,28 +276,11 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(fill.filled());
|
||||
let prefix = 1 + INDENT.chars().count() + 2;
|
||||
let name_budget = width.saturating_sub(prefix);
|
||||
|
||||
// docs/SIDEBAR.md, "Dormant cards": recovery resets a dormant
|
||||
// session's activity to Idle and there is nothing running to report
|
||||
// otherwise, so the rail carries no colour — the muted role, not the
|
||||
// idle one.
|
||||
let dormant = s.status == SessionStatus::Dormant;
|
||||
let muted_style = base.patch(
|
||||
surface
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
);
|
||||
|
||||
let edge = highlighted && !focused;
|
||||
let rail_style = if dormant {
|
||||
base.fg(surface.muted)
|
||||
} else {
|
||||
base.fg(activity_color(s.activity, fill))
|
||||
};
|
||||
let rail_style = base.fg(activity_color(s.activity, fill));
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
Span::styled(
|
||||
@@ -316,19 +291,18 @@ impl Sidebar {
|
||||
base
|
||||
},
|
||||
),
|
||||
Span::styled(
|
||||
format!("{glyph} "),
|
||||
if dormant { muted_style } else { base },
|
||||
),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
];
|
||||
// #131: a name the daemon generated is provisional — the session's
|
||||
// first prompt may replace it — so it reads muted, like the meta line,
|
||||
// rather than as a name someone chose. See docs/SIDEBAR.md.
|
||||
// A dormant card (#168, #169) is a muted variant of the whole card —
|
||||
// the same muted foreground `exited` already uses — regardless of
|
||||
// whether the name itself is provisional.
|
||||
let name_style = if dormant || maestro_protocol::display::is_provisional_name(s) {
|
||||
muted_style
|
||||
let name_style = if maestro_protocol::display::is_provisional_name(s) {
|
||||
base.patch(
|
||||
palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg)),
|
||||
)
|
||||
} else {
|
||||
base
|
||||
};
|
||||
@@ -354,18 +328,11 @@ impl Sidebar {
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill)));
|
||||
let muted = palette::active()
|
||||
.surface(fill.filled())
|
||||
.muted_style()
|
||||
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
|
||||
// docs/SIDEBAR.md, "Dormant cards": the rail carries no colour, so the
|
||||
// duration that follows it (rendered in the rail's own colour) is
|
||||
// muted rather than the idle role.
|
||||
let rail_style = if dormant {
|
||||
base.patch(muted)
|
||||
} else {
|
||||
base.patch(Style::default().fg(activity_color(s.activity, fill)))
|
||||
};
|
||||
let edge = highlighted && !focused;
|
||||
let mut spans = vec![
|
||||
Span::styled(RAIL, rail_style),
|
||||
@@ -491,10 +458,6 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
|
||||
spans
|
||||
}
|
||||
|
||||
fn dormant_rank(s: &Session) -> u8 {
|
||||
u8::from(s.status == SessionStatus::Dormant)
|
||||
}
|
||||
|
||||
/// `metadata.name` if present, else the command line.
|
||||
pub(crate) fn name_for(s: &Session) -> String {
|
||||
custom_name_for(s).unwrap_or_else(|| s.command.clone())
|
||||
@@ -1145,100 +1108,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards" (#169): a dormant session sorts
|
||||
/// after every live one in its folder, name notwithstanding.
|
||||
#[test]
|
||||
fn dormant_sessions_sort_after_live_ones_in_their_folder() {
|
||||
let mut dormant = session_named(1, "aaa-dormant");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
let live = session_named(2, "zzz-live");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![dormant, live],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["zzz-live", "aaa-dormant"]);
|
||||
}
|
||||
|
||||
/// Dormant sessions still sort by name against each other.
|
||||
#[test]
|
||||
fn dormant_sessions_still_sort_by_name_against_each_other() {
|
||||
let mut b = session_named(1, "b");
|
||||
b.status = SessionStatus::Dormant;
|
||||
let mut a = session_named(2, "a");
|
||||
a.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![b, a],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let names: Vec<String> = sb.groups(&HashSet::new())[0]
|
||||
.sessions
|
||||
.iter()
|
||||
.map(name_for)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md, "Dormant cards": a dormant card is a muted variant of
|
||||
/// the normal card — the same muted foreground `exited` already uses —
|
||||
/// covering the glyph and the name, not only the meta line.
|
||||
#[test]
|
||||
fn dormant_card_renders_its_name_and_glyph_muted() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let colour_of = |needle: char| {
|
||||
(0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == needle.to_string())
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("must be drawn")
|
||||
};
|
||||
assert_eq!(colour_of('c'), surface.muted, "the dormant name");
|
||||
assert_eq!(colour_of('\u{25cc}'), surface.muted, "the dormant glyph");
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: recovery resets a dormant session's activity to Idle
|
||||
/// and there is nothing running to report, so the rail is driven by the
|
||||
/// muted role directly rather than `activity_color`.
|
||||
#[test]
|
||||
fn dormant_card_rail_carries_no_colour() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
s.activity = ActivityState::Idle;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let buf = draw(&sb, 44, 8);
|
||||
let palette = palette::active();
|
||||
let surface = palette.surface(false);
|
||||
let rail_colour = (0..buf.area.height)
|
||||
.flat_map(|y| (0..buf.area.width).map(move |x| (x, y)))
|
||||
.find(|&(x, y)| buf[(x, y)].symbol() == RAIL)
|
||||
.map(|(x, y)| buf[(x, y)].fg)
|
||||
.expect("rail must be drawn");
|
||||
assert_eq!(rail_colour, surface.muted);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -358,11 +358,6 @@ The TUI binds these through its keymap, so a user can rebind any of them; the
|
||||
web client's are fixed. A binding added to one client belongs in this table and
|
||||
in both help overlays.
|
||||
|
||||
The TUI additionally binds `u` to resume the highlighted dormant session
|
||||
(#166, #169). The web client has no keystroke for it — its own resume
|
||||
affordance is the card's ↻ button and the context menu's **Resume** entry, per
|
||||
"Dormant cards" above — so this one has no web-side pair.
|
||||
|
||||
## Killing a session
|
||||
|
||||
**Every kill confirms first, in both clients** (#160). A kill is destructive
|
||||
|
||||
Reference in New Issue
Block a user