Compare commits

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

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

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

Closes #171

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:37:53 +01:00
Miguel Palhas e7830d15b0 merge: replace agent_session_id on a new report (#176)
Closes #176
2026-09-04 09:29:49 +01:00
Miguel Palhas 87f1e73dec fix(maestro): replace agent_session_id when a report names a new one
A resume issues a new conversation id, so write-once left the stored id
pointing at the pre-resume conversation and every later resume reattached
it. Rewrite the sidecar when the reported id differs from the stored one;
a repeated report of the same id still writes nothing.

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