Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b |
@@ -188,7 +188,6 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
let status = match s.status {
|
||||
SessionStatus::Running => "running",
|
||||
SessionStatus::Dead => "dead",
|
||||
SessionStatus::Dormant => "dormant",
|
||||
};
|
||||
let activity = match s.activity {
|
||||
ActivityState::Active => "active",
|
||||
@@ -253,7 +252,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
//! Tells a daemon restart from a host reboot (#164).
|
||||
//!
|
||||
//! `/proc/sys/kernel/random/boot_id` is fixed for the life of the kernel and
|
||||
//! changes on every boot. [`SessionManager::recover`] compares it against the
|
||||
//! value it stored on the previous run: a difference means every session from
|
||||
//! before is provably dead, so the tmux probe can be skipped and those
|
||||
//! sessions registered dormant directly instead of relying on tmux reporting
|
||||
//! an empty list.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Where the daemon reads the running kernel's boot id. Behind a trait so
|
||||
/// tests can simulate a changed (or unreadable) boot id without rebooting the
|
||||
/// machine.
|
||||
pub trait BootIdSource: std::fmt::Debug + Send + Sync + 'static {
|
||||
/// Current boot id, or `None` if it can't be determined (non-Linux,
|
||||
/// restricted `/proc`, missing or empty file).
|
||||
fn current(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Production source: `/proc/sys/kernel/random/boot_id`.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ProcBootId;
|
||||
|
||||
impl BootIdSource for ProcBootId {
|
||||
fn current(&self) -> Option<String> {
|
||||
read_boot_id(Path::new("/proc/sys/kernel/random/boot_id"))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_boot_id(path: &Path) -> Option<String> {
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
let trimmed = contents.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_trimmed_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("boot_id");
|
||||
std::fs::write(&path, "abc-123\n").unwrap();
|
||||
assert_eq!(read_boot_id(&path).as_deref(), Some("abc-123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(read_boot_id(&dir.path().join("missing")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_returns_none() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("boot_id");
|
||||
std::fs::write(&path, "\n").unwrap();
|
||||
assert_eq!(read_boot_id(&path), None);
|
||||
}
|
||||
}
|
||||
@@ -83,11 +83,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||
config.spawn_history_path(),
|
||||
config.spawn_history_days,
|
||||
))
|
||||
.with_boot_id(
|
||||
config.boot_id_path(),
|
||||
std::sync::Arc::new(crate::boot_id::ProcBootId),
|
||||
);
|
||||
));
|
||||
// Spawned sessions learn where to report back (#103). Derived from the
|
||||
// configured bind address; set before any spawn can happen.
|
||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||
|
||||
@@ -112,12 +112,6 @@ impl DaemonConfig {
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
self.state_dir.join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
/// Where recovery records the kernel boot id it saw last (#164):
|
||||
/// `<state_dir>/boot_id`.
|
||||
pub fn boot_id_path(&self) -> PathBuf {
|
||||
self.state_dir.join("boot_id")
|
||||
}
|
||||
}
|
||||
|
||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||
|
||||
@@ -12,7 +12,6 @@ pub mod attach;
|
||||
/// Naming a session after its first prompt (#131).
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
pub mod autoname;
|
||||
pub mod boot_id;
|
||||
pub mod branches;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
||||
@@ -307,11 +307,7 @@ async fn report_activity(
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ReportActivityRequest>,
|
||||
) -> Response {
|
||||
match s
|
||||
.manager
|
||||
.report_activity(id, body.state, body.detail, body.session_id)
|
||||
.await
|
||||
{
|
||||
match s.manager.report_activity(id, body.state, body.detail).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
|
||||
@@ -19,7 +19,6 @@ use maestro_protocol::{
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::boot_id::BootIdSource;
|
||||
use crate::panes::Panes;
|
||||
use crate::pty::{pipe_pane_command, PtyBridge};
|
||||
use crate::sidecar::{SidecarError, SidecarStore};
|
||||
@@ -63,10 +62,7 @@ pub struct SessionRecord {
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
pub worktree_base_repo: Option<PathBuf>,
|
||||
/// The PTY bridge feeding this session's attach stream. `None` for a
|
||||
/// `Dormant` session (#162): there is no pane behind it, so there is
|
||||
/// nothing to pipe and nothing to attach to.
|
||||
pub bridge: Option<PtyBridge>,
|
||||
pub bridge: PtyBridge,
|
||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||
/// outranks the activity pass's own inference; when it lapses, inference
|
||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||
@@ -180,25 +176,6 @@ struct Inner {
|
||||
/// client that reconnects never collides with the registration its
|
||||
/// previous socket has not finished releasing.
|
||||
next_viewport: AtomicU64,
|
||||
/// Boot-id tracking for `recover` (#164). `None` — the default — means
|
||||
/// reboot detection is unwired, which behaves exactly like a boot id
|
||||
/// that could never be read: always fall back to probing tmux.
|
||||
boot_id: Option<BootIdConfig>,
|
||||
}
|
||||
|
||||
/// Where recovery persists the boot id it last saw, and how it reads the
|
||||
/// current one. See [`crate::boot_id`].
|
||||
struct BootIdConfig {
|
||||
path: PathBuf,
|
||||
source: Arc<dyn BootIdSource>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for BootIdConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BootIdConfig")
|
||||
.field("path", &self.path)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl SessionManager {
|
||||
@@ -241,7 +218,6 @@ impl SessionManager {
|
||||
events,
|
||||
viewports: Mutex::new(Viewports::new()),
|
||||
next_viewport: AtomicU64::new(0),
|
||||
boot_id: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -271,21 +247,6 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Wire boot-id tracking for `recover` (#164). Builder, called once right
|
||||
/// after construction like the other `with_*` methods. Without it,
|
||||
/// `recover` always falls back to probing tmux — the same as a boot id
|
||||
/// that could never be read.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// If the handle has already been cloned.
|
||||
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");
|
||||
inner.boot_id = Some(BootIdConfig { path, source });
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the daemon's own HTTP base URL (#103), exported to every session
|
||||
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
|
||||
/// is a plain setter: the daemon runner learns its bound address after
|
||||
@@ -330,10 +291,7 @@ impl SessionManager {
|
||||
/// unknown to this daemon.
|
||||
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
sessions
|
||||
.get(&id)
|
||||
.and_then(|r| r.bridge.as_ref())
|
||||
.map(|b| b.subscribe())
|
||||
sessions.get(&id).map(|r| r.bridge.subscribe())
|
||||
}
|
||||
|
||||
/// Subscribe to a session's PTY broadcast and return the replay buffer
|
||||
@@ -352,26 +310,19 @@ impl SessionManager {
|
||||
let (result, updated) = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id)?;
|
||||
let SessionRecord {
|
||||
session, bridge, ..
|
||||
} = &mut *rec;
|
||||
// #162: a dormant session has no bridge and no pane — nothing was
|
||||
// ever piped for it, so there is no stream to hand back and the
|
||||
// attach reads as unknown. Checked before anything is stamped.
|
||||
let bridge = bridge.as_ref()?;
|
||||
session.last_focused_at = Some(Utc::now());
|
||||
let updated = session.clone();
|
||||
rec.session.last_focused_at = Some(Utc::now());
|
||||
let updated = rec.session.clone();
|
||||
// For a live session, tmux's own screen beats the byte tail: the
|
||||
// tail starts mid-stream, so a full-screen program (an agent TUI)
|
||||
// replayed from it renders as garbage until something forces a
|
||||
// repaint. A dead session has no pane left to capture, so it
|
||||
// keeps the byte tail.
|
||||
let alive = session.status == SessionStatus::Running;
|
||||
let alive = rec.session.status == SessionStatus::Running;
|
||||
// The capture shells out to tmux while the replay lock is held,
|
||||
// which parks the reader for the length of one tmux round-trip.
|
||||
// That only happens on attach, and the alternative is a seam in
|
||||
// the stream.
|
||||
let result = bridge.subscribe_with_snapshot(|| {
|
||||
let result = rec.bridge.subscribe_with_snapshot(|| {
|
||||
if !alive {
|
||||
return None;
|
||||
}
|
||||
@@ -527,7 +478,6 @@ impl SessionManager {
|
||||
// fresh tree has nothing in the way anyway.
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
};
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
@@ -549,7 +499,7 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
bridge: Some(bridge),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
@@ -701,21 +651,9 @@ impl SessionManager {
|
||||
/// and start a `PtyBridge`. Sessions whose sidecar references a `cwd`
|
||||
/// that no longer exists are still adopted — the spec does not require
|
||||
/// us to repair them.
|
||||
///
|
||||
/// #164: when the kernel's boot id has changed since the last run, the
|
||||
/// tmux probe below is skipped entirely — a reboot kills the tmux server,
|
||||
/// so every previous session is already dead and there is nothing tmux
|
||||
/// could truthfully adopt. [`Self::register_dormant`] then registers all
|
||||
/// of them directly.
|
||||
pub async fn recover(&self) -> Result<usize, SessionError> {
|
||||
tokio::fs::create_dir_all(&self.inner.fifo_dir).await?;
|
||||
let rebooted = self.check_boot_id().await;
|
||||
let live = if rebooted {
|
||||
// #164: every session from the previous boot is provably dead —
|
||||
// no tmux server survives a reboot — so there is nothing to gain
|
||||
// from asking it and no session it could honestly report back.
|
||||
Vec::new()
|
||||
} else {
|
||||
let live = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
tmux_blocking(move || tmux.list_sessions()).await?
|
||||
};
|
||||
@@ -745,7 +683,6 @@ impl SessionManager {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
},
|
||||
};
|
||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||
@@ -770,7 +707,7 @@ impl SessionManager {
|
||||
// directory blocks that branch forever (#54 review).
|
||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||
bridge: Some(bridge),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
@@ -785,144 +722,16 @@ impl SessionManager {
|
||||
.send(CatalogEvent::SessionAdded { session });
|
||||
adopted += 1;
|
||||
}
|
||||
self.register_dormant(rebooted).await;
|
||||
Ok(adopted)
|
||||
}
|
||||
|
||||
/// Compare the running kernel's boot id against the one recovery stored
|
||||
/// last time (#164), then persist the current one for next time. Returns
|
||||
/// `true` only when both are known and differ — a host reboot.
|
||||
///
|
||||
/// A missing stored value (first run) or an unreadable current one
|
||||
/// (non-Linux, restricted `/proc`, unwired in this build) both read as
|
||||
/// "no reboot", which is today's behaviour: fall back to probing tmux.
|
||||
async fn check_boot_id(&self) -> bool {
|
||||
let Some(cfg) = &self.inner.boot_id else {
|
||||
return false;
|
||||
};
|
||||
let Some(current) = cfg.source.current() else {
|
||||
return false;
|
||||
};
|
||||
let stored = tokio::fs::read_to_string(&cfg.path)
|
||||
.await
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
let rebooted = stored.is_some_and(|s| s != current);
|
||||
if let Some(parent) = cfg.path.parent() {
|
||||
let _ = tokio::fs::create_dir_all(parent).await;
|
||||
}
|
||||
if let Err(e) = tokio::fs::write(&cfg.path, ¤t).await {
|
||||
tracing::warn!(error = ?e, path = %cfg.path.display(), "recover: failed to persist boot id");
|
||||
}
|
||||
rebooted
|
||||
}
|
||||
|
||||
/// Second recovery pass (#162): register every sidecar tmux had nothing to
|
||||
/// adopt as a [`SessionStatus::Dormant`] session.
|
||||
///
|
||||
/// A host reboot kills the tmux server, so the first pass runs over an
|
||||
/// empty list and every session would otherwise disappear from the API
|
||||
/// while its sidecar sat untouched on disk. A dormant record keeps the id,
|
||||
/// name, cwd, command, worktree and `created_at` — everything the sidecar
|
||||
/// holds — so the operator still has a record of what was running. It has
|
||||
/// no tmux session and no [`PtyBridge`] behind it: it cannot be attached
|
||||
/// to, cannot be typed into, and is skipped by both watcher passes. The
|
||||
/// only thing left to do with one is dismiss it, which removes the sidecar
|
||||
/// so the next recovery does not bring it back.
|
||||
///
|
||||
/// Activity is not carried over: whatever the session was doing, it is not
|
||||
/// doing it now. The rest of the sidecar is left as written.
|
||||
///
|
||||
/// #163: a sidecar whose `cwd` no longer exists is pruned instead —
|
||||
/// nothing can be resumed in a directory that is gone, so the honest move
|
||||
/// is to delete the sidecar rather than register a record no operator can
|
||||
/// act on. A sidecar whose `cwd` still exists is kept regardless of age.
|
||||
///
|
||||
/// `rebooted` (#164) only changes what gets logged: a detected reboot
|
||||
/// names the count even when it is zero, so the operator sees that
|
||||
/// recovery told a reboot from a restart instead of a log that stays
|
||||
/// silent either way.
|
||||
async fn register_dormant(&self, rebooted: bool) {
|
||||
let stored = match self.inner.sidecar.load_all() {
|
||||
Ok(stored) => stored,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = ?e, "recover: reading sidecars for dormant sessions failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut registered = Vec::new();
|
||||
let mut pruned = 0usize;
|
||||
{
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
for mut session in stored {
|
||||
if sessions.contains_key(&session.id) {
|
||||
// Adopted live by the first pass; its sidecar is on disk too.
|
||||
continue;
|
||||
}
|
||||
if !session.cwd.exists() {
|
||||
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");
|
||||
}
|
||||
pruned += 1;
|
||||
continue;
|
||||
}
|
||||
let id = session.id;
|
||||
session.status = SessionStatus::Dormant;
|
||||
session.foreground = None;
|
||||
session.activity = ActivityState::Idle;
|
||||
session.activity_detail = None;
|
||||
sessions.insert(
|
||||
id,
|
||||
SessionRecord {
|
||||
session: session.clone(),
|
||||
fifo_path: self.inner.fifo_dir.join(format!("{id}.fifo")),
|
||||
// Still known, so a dismissal cleans the worktree up —
|
||||
// the same reason recovery restores it for a live
|
||||
// session (#54 review).
|
||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||
bridge: None,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
#[cfg(feature = "haiku-autoname")]
|
||||
naming: false,
|
||||
focused_since: None,
|
||||
awaiting_suppressed: false,
|
||||
},
|
||||
);
|
||||
registered.push(session);
|
||||
}
|
||||
}
|
||||
if rebooted {
|
||||
tracing::info!(
|
||||
dormant = registered.len(),
|
||||
"host rebooted, sessions dormant"
|
||||
);
|
||||
} else if !registered.is_empty() {
|
||||
tracing::info!(
|
||||
dormant = registered.len(),
|
||||
"registered sessions with no live tmux as dormant"
|
||||
);
|
||||
}
|
||||
if pruned > 0 {
|
||||
tracing::info!(pruned, "pruned sidecars whose cwd no longer exists");
|
||||
}
|
||||
for session in registered {
|
||||
let _ = self
|
||||
.inner
|
||||
.events
|
||||
.send(CatalogEvent::SessionAdded { session });
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward keystrokes to a session's pane. A dead or dormant session is
|
||||
/// read-only — its pane is gone, so this is a no-op rather than a tmux
|
||||
/// failure the client would have to interpret.
|
||||
/// Forward keystrokes to a session's pane. A dead session is read-only —
|
||||
/// its pane is gone, so this is a no-op rather than a tmux failure the
|
||||
/// client would have to interpret.
|
||||
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -935,15 +744,13 @@ impl SessionManager {
|
||||
/// Type `text` into a session's pane, with an Enter after it when
|
||||
/// `submit` is set.
|
||||
///
|
||||
/// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
|
||||
/// caller asked for text to land somewhere, and a silent success would let
|
||||
/// a script believe an agent had been told something it never heard.
|
||||
/// Unlike [`Self::send_keys`], a dead session is an error: the caller
|
||||
/// asked for text to land somewhere, and a silent success would let a
|
||||
/// script believe an agent had been told something it never heard.
|
||||
pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => {
|
||||
return Err(SessionError::NotRunning(id))
|
||||
}
|
||||
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -961,12 +768,12 @@ impl SessionManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resize a session's window. No-op for a dead or dormant session, as with
|
||||
/// Resize a session's window. No-op for a dead session, as with
|
||||
/// [`Self::send_keys`].
|
||||
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
|
||||
match self.status(id).await {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
|
||||
Some(SessionStatus::Dead) => return Ok(()),
|
||||
Some(SessionStatus::Running) => {}
|
||||
}
|
||||
let tmux = self.inner.tmux.clone();
|
||||
@@ -1074,18 +881,11 @@ impl SessionManager {
|
||||
/// A report that changes the effective state restarts `activity_since`
|
||||
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||
/// 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.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
state: ActivityState,
|
||||
detail: Option<String>,
|
||||
agent_session_id: Option<String>,
|
||||
) -> Result<(), SessionError> {
|
||||
let updated = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
@@ -1101,16 +901,7 @@ 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 {
|
||||
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.then(|| rec.session.clone())
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = self
|
||||
@@ -1383,11 +1174,7 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
rec.session.activity_detail = None;
|
||||
}
|
||||
}
|
||||
// Only `Running` sessions reach this pass, and every one of them
|
||||
// has a bridge; a dormant session has neither pane nor bridge.
|
||||
let Some(idle) = rec.bridge.as_ref().map(|b| b.idle_for()) else {
|
||||
continue;
|
||||
};
|
||||
let idle = rec.bridge.idle_for();
|
||||
// #105: while a report is in force it supplies the state and
|
||||
// nothing here may overwrite it; only an unreported session is
|
||||
// decided by inference.
|
||||
@@ -1505,10 +1292,8 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
||||
/// alone and stays attached to the dead record for a later forced dismissal
|
||||
/// (#81), which leaves it on disk too.
|
||||
///
|
||||
/// A dead session does not come back as dead across a daemon restart: its
|
||||
/// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
|
||||
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
|
||||
/// has a pane behind it.
|
||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||
/// adopts only what tmux still holds.
|
||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
let flipped = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
@@ -1517,9 +1302,7 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
record.session.status = SessionStatus::Dead;
|
||||
// Release anyone attached; the scrollback survives for a later
|
||||
// attach.
|
||||
if let Some(bridge) = record.bridge.as_mut() {
|
||||
bridge.seal();
|
||||
}
|
||||
record.bridge.seal();
|
||||
let session = record.session.clone();
|
||||
let worktree = record
|
||||
.worktree_base_repo
|
||||
|
||||
@@ -18,7 +18,6 @@ use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
sidecar: SidecarStore,
|
||||
_tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
@@ -26,17 +25,15 @@ struct Fixture {
|
||||
async fn fixture(ttl: Duration) -> Fixture {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||
let manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
sidecar.clone(),
|
||||
SidecarStore::new(state_dir.path().join("sessions")),
|
||||
WorktreeManager::new(None),
|
||||
state_dir.path().join("fifos"),
|
||||
)
|
||||
.with_activity_report_ttl(ttl);
|
||||
Fixture {
|
||||
manager,
|
||||
sidecar,
|
||||
_tmux: tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
@@ -70,7 +67,7 @@ async fn report_on_unknown_session_is_not_found() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let err = fx
|
||||
.manager
|
||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
|
||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||
@@ -86,7 +83,6 @@ async fn report_sets_error_state_and_detail() {
|
||||
id,
|
||||
ActivityState::Error,
|
||||
Some("cargo build failed: link error".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -105,7 +101,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some("first failure".into()), None)
|
||||
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
@@ -113,7 +109,6 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||
id,
|
||||
ActivityState::AwaitingInput,
|
||||
Some("needs approval".into()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -130,7 +125,7 @@ async fn detail_is_capped_server_side() {
|
||||
|
||||
let long = "x".repeat(10_000);
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some(long), None)
|
||||
.report_activity(id, ActivityState::Error, Some(long))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -149,12 +144,7 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
|
||||
fx.manager
|
||||
.report_activity(
|
||||
id,
|
||||
ActivityState::Error,
|
||||
Some("agent died mid-run".into()),
|
||||
None,
|
||||
)
|
||||
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -172,55 +162,3 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_recorded_on_the_session() {
|
||||
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-abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
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.
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_is_written_once_and_never_replaced() {
|
||||
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-second".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-first"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reported_session_id_survives_recovery_from_the_sidecar() {
|
||||
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-abc123".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stored = fx
|
||||
.sidecar
|
||||
.read(id)
|
||||
.expect("sidecar read")
|
||||
.expect("sidecar present");
|
||||
assert_eq!(stored.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
@@ -526,33 +526,6 @@ async fn post_activity_reports_error_with_detail() {
|
||||
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||
}
|
||||
|
||||
/// #161: a reported `session_id` lands on the session as `agent_session_id`.
|
||||
#[tokio::test]
|
||||
async fn post_activity_records_the_reported_session_id() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let id = spawn_via_manager(&h).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({
|
||||
"state": "Active",
|
||||
"session_id": "conv-abc123"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||
assert_eq!(s.agent_session_id.as_deref(), Some("conv-abc123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_activity_to_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
|
||||
@@ -140,356 +140,6 @@ async fn recover_adopts_existing_tmux_sessions() {
|
||||
assert_eq!(snap[0].command, "sleep 30");
|
||||
}
|
||||
|
||||
/// #162: a host reboot kills the tmux server, so recovery finds nothing to
|
||||
/// adopt while every sidecar is still on disk. Those sessions come back as
|
||||
/// `Dormant` — a record of what was running — rather than vanishing from the
|
||||
/// API and leaving the operator with nothing.
|
||||
#[tokio::test]
|
||||
async fn recover_registers_orphaned_sidecars_as_dormant() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert(
|
||||
maestro_protocol::NAME_KEY.to_string(),
|
||||
serde_json::Value::String("after-reboot".into()),
|
||||
);
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// The reboot: the tmux server is gone, the sidecar is untouched.
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx);
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 0, "no live tmux session left to adopt");
|
||||
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap.len(), 1, "the sidecar's session is still listed");
|
||||
assert_eq!(snap[0].id, s.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
assert_eq!(snap[0].command, "sleep 30");
|
||||
assert_eq!(snap[0].cwd, s.cwd);
|
||||
assert_eq!(snap[0].created_at, s.created_at);
|
||||
assert_eq!(
|
||||
snap[0]
|
||||
.metadata
|
||||
.get(maestro_protocol::NAME_KEY)
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("after-reboot"),
|
||||
);
|
||||
}
|
||||
|
||||
/// #162: a session tmux still holds is adopted `Running` by the first pass and
|
||||
/// must not be re-registered as dormant by the second — the sidecar is on disk
|
||||
/// for both.
|
||||
#[tokio::test]
|
||||
async fn recover_does_not_mark_adopted_sessions_dormant() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let live = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create live");
|
||||
let orphan = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 31".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create orphan");
|
||||
// Only the second session loses its pane.
|
||||
fx.tmux.tmux.kill_session(orphan.id).expect("kill session");
|
||||
|
||||
let revived = revive(&fx);
|
||||
assert_eq!(revived.recover().await.expect("recover"), 1);
|
||||
|
||||
let mut snap = revived.snapshot().await;
|
||||
snap.sort_by_key(|s| s.command.clone());
|
||||
assert_eq!(snap.len(), 2);
|
||||
assert_eq!(snap[0].id, live.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Running);
|
||||
assert_eq!(snap[1].id, orphan.id);
|
||||
assert_eq!(snap[1].status, SessionStatus::Dormant);
|
||||
}
|
||||
|
||||
/// #163: a sidecar whose `cwd` no longer exists is pruned instead of
|
||||
/// registered dormant — the worktree it pointed at is gone, so there is
|
||||
/// nothing left to resume.
|
||||
#[tokio::test]
|
||||
async fn recover_deletes_sidecar_when_cwd_gone() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd_path.clone(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
drop(cwd);
|
||||
std::fs::remove_dir_all(&cwd_path).ok();
|
||||
assert!(!cwd_path.exists());
|
||||
|
||||
let revived = revive(&fx);
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 0);
|
||||
|
||||
assert!(!revived.has(s.id).await);
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
}
|
||||
|
||||
/// #163: a sidecar whose `cwd` still exists is left alone regardless of age
|
||||
/// — it is registered dormant like any other orphan.
|
||||
#[tokio::test]
|
||||
async fn recover_keeps_dormant_sidecar_when_cwd_exists() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx);
|
||||
revived.recover().await.expect("recover");
|
||||
|
||||
assert!(revived.has(s.id).await);
|
||||
assert!(fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
}
|
||||
|
||||
/// #164: a fake [`maestro_daemon::boot_id::BootIdSource`] lets tests simulate
|
||||
/// a changed (or unreadable) boot id without rebooting the machine.
|
||||
#[derive(Debug)]
|
||||
struct FakeBootId(Option<String>);
|
||||
|
||||
impl maestro_daemon::boot_id::BootIdSource for FakeBootId {
|
||||
fn current(&self) -> Option<String> {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn revive_with_boot_id(
|
||||
fx: &DaemonFixture,
|
||||
boot_id_path: PathBuf,
|
||||
current: Option<&str>,
|
||||
) -> maestro_daemon::SessionManager {
|
||||
maestro_daemon::SessionManager::new(
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
fx.fifo_dir(),
|
||||
)
|
||||
.with_boot_id(
|
||||
boot_id_path,
|
||||
Arc::new(FakeBootId(current.map(String::from))),
|
||||
)
|
||||
}
|
||||
|
||||
/// #164: a changed boot id means every session from the previous boot is
|
||||
/// already dead, so recovery must not trust tmux to say so — it skips the
|
||||
/// probe outright. Proven by leaving the tmux session alive: if the probe
|
||||
/// ran, it would come back `Running`, not `Dormant`.
|
||||
#[tokio::test]
|
||||
async fn recover_skips_tmux_probe_when_boot_id_changed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let boot_id_path = fx.state_dir.path().join("boot_id");
|
||||
std::fs::write(&boot_id_path, "previous-boot").unwrap();
|
||||
|
||||
// tmux session is still alive — a probe would find it if one ran.
|
||||
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 0, "reboot detected: nothing adopted live");
|
||||
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].id, s.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Dormant);
|
||||
|
||||
// The current boot id is now the stored one, for the next comparison.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
|
||||
"current-boot"
|
||||
);
|
||||
}
|
||||
|
||||
/// #164: an unchanged boot id is a plain restart — recovery probes tmux as
|
||||
/// before and adopts the still-live session `Running`.
|
||||
#[tokio::test]
|
||||
async fn recover_probes_tmux_when_boot_id_unchanged() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let boot_id_path = fx.state_dir.path().join("boot_id");
|
||||
std::fs::write(&boot_id_path, "same-boot").unwrap();
|
||||
|
||||
let revived = revive_with_boot_id(&fx, boot_id_path, Some("same-boot"));
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 1);
|
||||
let snap = revived.snapshot().await;
|
||||
assert_eq!(snap[0].id, s.id);
|
||||
assert_eq!(snap[0].status, SessionStatus::Running);
|
||||
}
|
||||
|
||||
/// #164: no stored boot id (first run ever, or an older build that never
|
||||
/// wrote one) means there is nothing to compare against — falls back to
|
||||
/// today's probe-based recovery rather than assuming a reboot.
|
||||
#[tokio::test]
|
||||
async fn recover_falls_back_to_probe_when_no_stored_boot_id() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let boot_id_path = fx.state_dir.path().join("boot_id");
|
||||
assert!(!boot_id_path.exists());
|
||||
|
||||
let revived = revive_with_boot_id(&fx, boot_id_path.clone(), Some("current-boot"));
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 1, "no stored boot id; falls back to probing tmux");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&boot_id_path).unwrap().trim(),
|
||||
"current-boot"
|
||||
);
|
||||
}
|
||||
|
||||
/// #164: an unreadable current boot id (non-Linux, restricted `/proc`) falls
|
||||
/// back to today's behaviour rather than failing startup or assuming a
|
||||
/// reboot.
|
||||
#[tokio::test]
|
||||
async fn recover_falls_back_to_probe_when_boot_id_unreadable() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let boot_id_path = fx.state_dir.path().join("boot_id");
|
||||
std::fs::write(&boot_id_path, "stored-boot").unwrap();
|
||||
|
||||
let revived = revive_with_boot_id(&fx, boot_id_path, None);
|
||||
let adopted = revived.recover().await.expect("recover");
|
||||
assert_eq!(adopted, 1, "boot id unreadable; falls back to probing tmux");
|
||||
}
|
||||
|
||||
/// #162: dismissing a dormant session is the one thing that can be done to it
|
||||
/// — no tmux call to fail, and the sidecar goes with it so the next recovery
|
||||
/// does not resurrect it.
|
||||
#[tokio::test]
|
||||
async fn dormant_session_can_be_dismissed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||
|
||||
let revived = revive(&fx);
|
||||
revived.recover().await.expect("recover");
|
||||
revived.kill(s.id, false).await.expect("dismiss");
|
||||
|
||||
assert!(!revived.has(s.id).await);
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
}
|
||||
|
||||
/// A second `SessionManager` over the same dirs and the same tmux server —
|
||||
/// what a daemon restart looks like from inside a test.
|
||||
fn revive(fx: &DaemonFixture) -> maestro_daemon::SessionManager {
|
||||
maestro_daemon::SessionManager::new(
|
||||
Arc::new(fx.tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
fx.fifo_dir(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Regression for NAP-253: the very first bytes a freshly-spawned command
|
||||
/// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
|
||||
/// separate tmux invocation that ran after `new-session` + an async FIFO
|
||||
|
||||
@@ -32,7 +32,6 @@ fn sample(id: Uuid) -> Session {
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: Some("conv-abc123".into()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,23 +84,15 @@ if [ "$state" = "Prompt" ]; then
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
fields="\"state\":\"$state\""
|
||||
body="{\"state\":\"$state\"}"
|
||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||
# prose) would risk breaking the JSON body, and the spelling already names
|
||||
# the failure.
|
||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||
if [ -n "$detail" ]; then
|
||||
fields="$fields,\"detail\":\"$detail\""
|
||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||
fi
|
||||
# Every Claude Code hook payload carries its own conversation id (#161).
|
||||
# Constrained to a safe charset for the same reason as "detail" above: it is
|
||||
# spliced into the JSON body unescaped.
|
||||
session_id=$(printf '%s' "$payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([A-Za-z0-9_-]*\)".*/\1/p')
|
||||
if [ -n "$session_id" ]; then
|
||||
fields="$fields,\"session_id\":\"$session_id\""
|
||||
fi
|
||||
body="{$fields}"
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body" \
|
||||
|
||||
@@ -128,41 +128,6 @@ fn script_reports_the_error_spelling_as_detail() {
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// #161: the payload's own `session_id` — the agent's conversation id — is
|
||||
/// parsed out and forwarded in the activity body, so the daemon can persist
|
||||
/// it on the session.
|
||||
#[test]
|
||||
fn script_forwards_the_payloads_session_id_in_the_activity_body() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"conv-abc123","hook_event_name":"PreToolUse"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
assert!(
|
||||
request.contains(r#""session_id":"conv-abc123""#),
|
||||
"{request}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A payload without a `session_id` (or one with an unexpected shape) must
|
||||
/// never produce a broken body — no `session_id` field is posted.
|
||||
#[test]
|
||||
fn script_posts_no_session_id_when_the_payload_has_none() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["AwaitingInput"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||
)
|
||||
});
|
||||
assert!(!request.contains("session_id"), "{request}");
|
||||
}
|
||||
|
||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||
/// one at all) must never produce a broken body — no detail is posted.
|
||||
#[test]
|
||||
|
||||
@@ -284,7 +284,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,13 +116,6 @@ pub struct Session {
|
||||
/// sorts a never-focused session after every session that has one.
|
||||
#[serde(default)]
|
||||
pub last_focused_at: Option<DateTime<Utc>>,
|
||||
/// The conversation id the agent running in this session reported for
|
||||
/// itself (#161), parsed from a hook's `session_id` field. Written once,
|
||||
/// from the first activity report that carries one, and left alone after
|
||||
/// — an agent's own session id does not change mid-session. `None` for
|
||||
/// sessions that never run an agent (plain shells) or predate the field.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||
@@ -130,38 +123,10 @@ fn epoch() -> DateTime<Utc> {
|
||||
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
|
||||
}
|
||||
|
||||
/// Lifecycle of a session as the daemon knows it.
|
||||
///
|
||||
/// Deserializes leniently, like [`ActivityState`] (#162): an unknown spelling
|
||||
/// from a newer daemon reads as [`SessionStatus::Dead`] instead of failing the
|
||||
/// whole `Session`. `Dead` is the safe degrade — an unrecognised session is
|
||||
/// one this client must not treat as having a live pane to type into.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SessionStatus {
|
||||
Running,
|
||||
Dead,
|
||||
/// Known from its sidecar, with no tmux session behind it (#162). The
|
||||
/// daemon registers these at recovery time for every sidecar tmux had
|
||||
/// nothing to adopt — after a host reboot, that is every session there
|
||||
/// was. There is no pane and no PTY bridge: a dormant session is a record
|
||||
/// of what was running, listed so the operator still has it, and the only
|
||||
/// thing that can be done to it is dismissing it.
|
||||
Dormant,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SessionStatus {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(match s.as_str() {
|
||||
"Running" => Self::Running,
|
||||
"Dormant" => Self::Dormant,
|
||||
// Known or not, any other spelling degrades to the inert variant.
|
||||
_ => Self::Dead,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||
@@ -219,11 +184,6 @@ pub struct ReportActivityRequest {
|
||||
/// The daemon caps its length server-side.
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
/// The agent's own conversation id (#161), forwarded from the hook
|
||||
/// payload's `session_id` field. `None` for hook events that don't carry
|
||||
/// one, or hooks predating this field.
|
||||
#[serde(default)]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||
@@ -902,7 +862,6 @@ mod tests {
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1072,7 +1031,6 @@ mod tests {
|
||||
let full = ReportActivityRequest {
|
||||
state: ActivityState::AwaitingInput,
|
||||
detail: Some("waiting on permission".into()),
|
||||
session_id: Some("conv-abc123".into()),
|
||||
};
|
||||
assert_eq!(full, round_trip(&full));
|
||||
|
||||
@@ -1101,33 +1059,6 @@ mod tests {
|
||||
serde_json::to_value(SessionStatus::Dead).unwrap(),
|
||||
serde_json::json!("Dead")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(SessionStatus::Dormant).unwrap(),
|
||||
serde_json::json!("Dormant")
|
||||
);
|
||||
}
|
||||
|
||||
// #162: a session with no tmux behind it still rides the wire whole.
|
||||
#[test]
|
||||
fn dormant_session_round_trips() {
|
||||
let mut s = sample_session();
|
||||
s.status = SessionStatus::Dormant;
|
||||
assert_eq!(s, round_trip(&s));
|
||||
}
|
||||
|
||||
// #162: a `SessionStatus` this client does not know must degrade to the
|
||||
// inert variant, not fail the whole `Session` the way a derived
|
||||
// `Deserialize` would.
|
||||
#[test]
|
||||
fn unknown_session_status_degrades_to_dead() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<SessionStatus>(serde_json::json!("Hibernating")).unwrap(),
|
||||
SessionStatus::Dead
|
||||
);
|
||||
let mut value = serde_json::to_value(sample_session()).unwrap();
|
||||
value["status"] = serde_json::json!("Hibernating");
|
||||
let s: Session = serde_json::from_value(value).expect("session survives unknown status");
|
||||
assert_eq!(s.status, SessionStatus::Dead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1898,7 +1898,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
@@ -2080,7 +2079,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::SidebarNext);
|
||||
@@ -2108,7 +2106,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3217,7 +3214,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: focused,
|
||||
agent_session_id: None,
|
||||
};
|
||||
let older = Utc::now() - chrono::Duration::seconds(60);
|
||||
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||
@@ -3496,7 +3492,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
app.dispatch(Action::KillHighlighted);
|
||||
@@ -3532,7 +3527,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -223,7 +223,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,9 +194,7 @@ pub enum BarOutcome {
|
||||
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
|
||||
let mut ordered: Vec<&Session> = sessions.iter().collect();
|
||||
ordered.sort_by(|a, b| {
|
||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant
|
||||
// (#162) — sorts after the sessions you could actually attach to.
|
||||
let dead = |s: &Session| u8::from(s.status != SessionStatus::Running);
|
||||
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead);
|
||||
dead(a)
|
||||
.cmp(&dead(b))
|
||||
.then_with(|| {
|
||||
@@ -321,7 +319,7 @@ impl CommandBar {
|
||||
.map(|s| BarEntry {
|
||||
candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
|
||||
target: BarTarget::Session(s.id),
|
||||
dead: s.status != SessionStatus::Running,
|
||||
dead: s.status == SessionStatus::Dead,
|
||||
chords: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
@@ -811,7 +809,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -892,23 +889,6 @@ mod tests {
|
||||
assert!(bar.rows()[1].dead);
|
||||
}
|
||||
|
||||
/// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by
|
||||
/// which way the pane went away — a dormant session sorts with the dead.
|
||||
#[test]
|
||||
fn dormant_sessions_sort_after_live_ones_however_recent() {
|
||||
let mut dormant = named(1, "dormant");
|
||||
dormant.status = SessionStatus::Dormant;
|
||||
dormant.last_focused_at = Some(at(99));
|
||||
let live = named(2, "live");
|
||||
|
||||
let bar = CommandBar::new(&[dormant, live], &[], "/home/me");
|
||||
assert_eq!(
|
||||
ids(bar.rows()),
|
||||
vec![Uuid::from_u128(2), Uuid::from_u128(1)]
|
||||
);
|
||||
assert!(bar.rows()[1].dead);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_ranks_through_the_matcher_and_returns_ranges() {
|
||||
let mut bar = CommandBar::new(
|
||||
|
||||
@@ -268,8 +268,6 @@ impl Sidebar {
|
||||
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
// #162: known from its sidecar, nothing running behind it.
|
||||
(SessionStatus::Dormant, _) => "◌",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
@@ -324,7 +322,6 @@ impl Sidebar {
|
||||
// on line 1; the kill confirm names the specifics.
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let dormant = s.status == SessionStatus::Dormant;
|
||||
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||
let fill = self.card_fill(s, focused);
|
||||
let base = card_base(fill, focused);
|
||||
@@ -364,9 +361,6 @@ impl Sidebar {
|
||||
if dead {
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
if dormant {
|
||||
tail.push(Span::styled(" dormant", muted));
|
||||
}
|
||||
// docs/SIDEBAR.md: the card names the program, never its arguments.
|
||||
let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
|
||||
|
||||
@@ -506,7 +500,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,39 +1068,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #162, docs/SIDEBAR.md: a dormant session is known but not running —
|
||||
/// its own glyph on line 1 and the muted `dormant` fragment on line 2, so
|
||||
/// it never reads as something that exited.
|
||||
#[test]
|
||||
fn dormant_session_reads_as_known_but_not_running() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dormant;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
pane_session: None,
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
assert!(
|
||||
all[idx].contains('\u{25cc}'),
|
||||
"expected the dormant glyph in: {:?}",
|
||||
all[idx]
|
||||
);
|
||||
assert!(
|
||||
all[idx + 1].contains("dormant"),
|
||||
"expected dormant fragment in: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
assert!(
|
||||
!all[idx + 1].contains("exited"),
|
||||
"a dormant session never reads as exited: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: line 2 names the program, never its arguments.
|
||||
#[test]
|
||||
fn meta_line_drops_the_commands_arguments() {
|
||||
@@ -1306,7 +1266,6 @@ mod tests {
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
last_focused_at: None,
|
||||
agent_session_id: None,
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
|
||||
+3
-3
@@ -123,9 +123,9 @@ client opening `/api/attach/:id`, and a client reporting focus explicitly at
|
||||
`POST /api/sessions/:id/focus` when it refocuses a pane that is already open.
|
||||
|
||||
**A session that has never been focused sorts after every session that has**,
|
||||
and among those, most recently created first. Sessions with no live pane —
|
||||
`Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
|
||||
session you cannot attach to is not where you just were.
|
||||
and among those, most recently created first. Dead sessions sort after live
|
||||
ones regardless of recency: a session you cannot attach to is not where you
|
||||
just were.
|
||||
|
||||
Under `>` an empty query lists every command in registry order. Under `~` it
|
||||
lists recent dirs, most recent first, then the project list.
|
||||
|
||||
@@ -47,6 +47,7 @@ Web client (`--preview` and friends in `web/src/styles.css`):
|
||||
| The canvas | A `preview` chip, top right, and a dashed inset ring. |
|
||||
| A throwaway tab | Dashed border, italic label. |
|
||||
| The sidebar row being previewed | Filled with the preview hue, over whatever open state the row already had — but the row of the session already showing in the pane keeps its accent fill, since previewing it changes nothing on screen. |
|
||||
| The sidebar row of the session the operator picked | Keeps its accent fill for the whole preview. A preview never moves the accent; only opening another session does. |
|
||||
| A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. |
|
||||
|
||||
Rows say it with fill, not with an edge — see the tier ladder in
|
||||
|
||||
@@ -68,26 +68,6 @@ resolves, so a blocked client is never a trapped one. It clears itself the
|
||||
moment a probe succeeds — there is no "retry" button, because the client is
|
||||
already retrying.
|
||||
|
||||
## What survives, and what comes back dormant
|
||||
|
||||
A daemon restart leaves tmux alone, so recovery re-adopts every
|
||||
`maestro-<uuid>` session tmux still holds and the operator sees no change
|
||||
beyond the reconnect above.
|
||||
|
||||
A **host reboot** is different: it kills the tmux server, so there is nothing
|
||||
to adopt while every session's sidecar is still on disk. Recovery makes a
|
||||
second pass over those sidecars and registers each as a `Dormant` session
|
||||
(#162) — id, name, cwd, command, worktree and `created_at` intact. A dormant
|
||||
session has no pane and no PTY bridge: it cannot be attached to, typed into or
|
||||
resized, and neither watcher pass touches it. It is a record of what was
|
||||
running, and the only thing left to do with one is dismiss it, which removes
|
||||
the sidecar so the next recovery does not bring it back.
|
||||
|
||||
`SessionStatus` is a wire type, so it deserializes leniently on both clients:
|
||||
a spelling this build does not know reads as `Dead` rather than failing the
|
||||
whole session. Clients render a dormant session as known-but-not-running —
|
||||
see `docs/SIDEBAR.md` for the card and `docs/COMMAND-BAR.md` for how it ranks.
|
||||
|
||||
## Timing
|
||||
|
||||
- **Grace window** — 1200 ms between the socket dropping and the overlay
|
||||
|
||||
+4
-10
@@ -56,7 +56,7 @@ itself is not.
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
[<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
|
||||
[<duration>] [<detail>] <command> [<diff stat> | exited]
|
||||
```
|
||||
|
||||
Its minimum content is therefore just the command.
|
||||
@@ -104,14 +104,8 @@ fragment; it no longer decides whether line 2 exists.
|
||||
while an agent-reported state is in force (see Precedence): when the report
|
||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
|
||||
neither client ever lets it wrap.
|
||||
|
||||
`dormant` (#162) is the counterpart of `exited` for a session the daemon knows
|
||||
only from its sidecar: recovery found no tmux session behind it, so it has no
|
||||
pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
||||
observed ending — so the two fragments never both show. The TUI marks the same
|
||||
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||
@@ -215,7 +209,7 @@ the cursor's fill while the pane holds the keyboard, and the web's ring on a
|
||||
highlighted **folder** row — a folder previews nothing, so it has no fill to
|
||||
step up to.
|
||||
|
||||
The duration text inherits its state's colour; the exited, dormant and command
|
||||
The duration text inherits its state's colour; the exited and command
|
||||
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and
|
||||
`↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
|
||||
take the `active` and `error` roles of the same surface.
|
||||
|
||||
@@ -414,19 +414,6 @@ describe("session cards (#107)", () => {
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
// #162: a session recovered with no tmux behind it is known but not running,
|
||||
// and never reads as something that exited.
|
||||
it("shows dormant on line 2 for dormant sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000118",
|
||||
status: "Dormant",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("dormant");
|
||||
expect(line2?.textContent).not.toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
|
||||
@@ -113,9 +113,6 @@ function activityRailMarkup(slug: string, stateWord: string): string {
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const dead = s.status === "Dead";
|
||||
// #162: known from its sidecar with nothing running behind it — a distinct
|
||||
// fragment, since it never exited.
|
||||
const dormant = s.status === "Dormant";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
@@ -130,7 +127,6 @@ function metaLine(s: Session, now: number): string {
|
||||
commandFragment(s.command),
|
||||
blockers ? blockedFragment(blockers) : "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
dormant ? `<span class="card-flag">dormant</span>` : "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
@@ -99,20 +99,6 @@ describe("empty-query ordering", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
// #162: docs/COMMAND-BAR.md ranks by whether you could attach, not by which
|
||||
// way the pane went away.
|
||||
it("sorts dormant sessions after live ones, like dead ones", () => {
|
||||
const dormantRecent = session("dormant", {
|
||||
status: "Dormant",
|
||||
last_focused_at: "2026-06-01T00:00:00Z",
|
||||
});
|
||||
const liveStale = session("live", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
expect(orderSessionsForBar([dormantRecent, liveStale]).map((s) => s.id)).toEqual([
|
||||
"live",
|
||||
"dormant",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a null last_focused_at as never focused", () => {
|
||||
const nulled = session("nulled", { last_focused_at: null });
|
||||
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
|
||||
|
||||
@@ -142,10 +142,8 @@ function createdAt(s: Session): number {
|
||||
* matcher's last tie-break preserves. */
|
||||
export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
|
||||
return [...sessions].sort((a, b) => {
|
||||
// docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
|
||||
// — sorts after the sessions you could actually attach to.
|
||||
const deadA = a.status === "Running" ? 0 : 1;
|
||||
const deadB = b.status === "Running" ? 0 : 1;
|
||||
const deadA = a.status === "Dead" ? 1 : 0;
|
||||
const deadB = b.status === "Dead" ? 1 : 0;
|
||||
if (deadA !== deadB) return deadA - deadB;
|
||||
|
||||
const focusA = focusedAt(a);
|
||||
@@ -352,7 +350,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
|
||||
row.dataset.kind = candidate.kind;
|
||||
if (candidate.kind === "session") {
|
||||
row.dataset.sessionId = candidate.session.id;
|
||||
if (candidate.session.status !== "Running") row.classList.add("is-dead");
|
||||
if (candidate.session.status === "Dead") row.classList.add("is-dead");
|
||||
} else if (isActionCandidate(candidate)) {
|
||||
row.dataset.actionId = candidate.action.id;
|
||||
} else if (candidate.kind === "command") {
|
||||
|
||||
@@ -34,12 +34,14 @@ describe("resolveFastForward (#67)", () => {
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
expect(res.handle).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
// No name (#131): the daemon names the session after its first prompt,
|
||||
// and a name here would collide with the branch handle.
|
||||
expect(req.metadata).toBeUndefined();
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
@@ -88,9 +90,9 @@ describe("resolveFastForward (#67)", () => {
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades to a draft when the generated name is missing", () => {
|
||||
it("downgrades to a draft when the generated handle is missing", () => {
|
||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||
expect(res.name).toBeUndefined();
|
||||
expect(res.handle).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -108,6 +110,17 @@ describe("resolveFastForward (#67)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds the handle as the branch and leaves the name field empty (#131)", () => {
|
||||
const res = resolveFastForward(
|
||||
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||
presets,
|
||||
"amber-falcon",
|
||||
);
|
||||
const seed = fastForwardSeed(res);
|
||||
expect(seed.name).toBe("");
|
||||
expect(seed.worktreeBranch).toBe("amber-falcon");
|
||||
});
|
||||
|
||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||
// so the fallback draft would disagree with a normally-opened draft and
|
||||
|
||||
+19
-13
@@ -10,19 +10,21 @@ import { sortProjects } from "./projectList.js";
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
name?: string;
|
||||
/** A generated handle for the worktree branch (#131). Not a session name:
|
||||
* the fast path names no session. */
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
/** The defaults: the currently focused session's project when it's still a
|
||||
* known project, else the most recent one (fresh-first, same ordering the
|
||||
* picker shows) — so `N` lands where the operator currently is, matching
|
||||
* what plain `n` seeds from `spawnSimilarProjectSeed`. Its last command
|
||||
* falls back to the first non-custom preset, and the name is freshly
|
||||
* generated when the daemon supplied one. */
|
||||
* falls back to the first non-custom preset, and the branch handle is
|
||||
* freshly generated when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
handle?: string,
|
||||
focusedCwd?: string,
|
||||
): FastForwardResolution {
|
||||
const project =
|
||||
@@ -30,41 +32,45 @@ export function resolveFastForward(
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
return { project, command, name: name || undefined };
|
||||
return { project, command, handle: handle || undefined };
|
||||
}
|
||||
|
||||
/** The immediate spawn request — worktree on, new branch named after the
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
* generated handle — or `null` when any default is missing.
|
||||
*
|
||||
* The request carries no name (#131): the daemon assigns a provisional one
|
||||
* and replaces it once the session sees its first prompt. A name here would
|
||||
* also collide with the branch handle's own session. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
const { project, command, handle } = res;
|
||||
if (!project || !command?.trim() || !handle) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: name,
|
||||
branch: handle,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback spawn form: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. */
|
||||
* form; the rest stays with its own defaults. The name field stays empty
|
||||
* (#131) — the handle seeds the branch, not the session name. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: res.name ?? "",
|
||||
name: "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback form
|
||||
// would disagree with both a normally-opened form and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.name ? res.name : undefined,
|
||||
res.project && res.handle ? res.handle : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
closeForceKillDialog,
|
||||
openForceKillDialog,
|
||||
openKillConfirmDialog,
|
||||
} from "./killDialog.js";
|
||||
|
||||
describe("kill dialogs", () => {
|
||||
beforeEach(() => {
|
||||
closeForceKillDialog();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("focuses the kill button, so Enter confirms", () => {
|
||||
const el = openKillConfirmDialog({
|
||||
name: "alpha",
|
||||
blockers: null,
|
||||
onKill: () => Promise.resolve(),
|
||||
});
|
||||
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-kill"));
|
||||
});
|
||||
|
||||
it("focuses the force button too", () => {
|
||||
const el = openForceKillDialog({
|
||||
name: "alpha",
|
||||
blockers: null,
|
||||
reason: "worktree is dirty.",
|
||||
onForce: () => Promise.resolve(),
|
||||
});
|
||||
expect(document.activeElement).toBe(el.querySelector(".kill-dialog-force"));
|
||||
});
|
||||
});
|
||||
@@ -132,9 +132,9 @@ function mount(p: KillDialogSpec): HTMLElement {
|
||||
};
|
||||
window.addEventListener("keydown", onKeydown, true);
|
||||
open = { el, onKeydown };
|
||||
// Cancel takes the focus in both prompts: a kill is never one keystroke
|
||||
// away from whatever asked for the dialog.
|
||||
cancel.focus();
|
||||
// The acting button takes the focus in both prompts: the operator already
|
||||
// asked for the kill, so Enter confirms it. Escape and the scrim cancel.
|
||||
confirm.focus();
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -356,14 +356,14 @@ async function spawnPresets(): Promise<SpawnPreset[]> {
|
||||
}
|
||||
|
||||
async function requestFastForward(): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
const presets = await spawnPresets();
|
||||
const resolved = resolveFastForward(discovered, presets, name, focusedProjectCwd());
|
||||
const resolved = resolveFastForward(discovered, presets, handle, focusedProjectCwd());
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
@@ -390,7 +390,7 @@ async function requestFastForward(): Promise<void> {
|
||||
// one outside any repo spawns without a worktree, since there is no repo to cut
|
||||
// a branch from. Anything still unresolved opens the pre-filled form.
|
||||
async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const [discovered, name] = await Promise.all([
|
||||
const [discovered, handle] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
@@ -399,7 +399,7 @@ async function requestSpawnInDirectory(path: string): Promise<void> {
|
||||
const presets = await spawnPresets();
|
||||
const known = discovered.find((p) => p.path === path);
|
||||
const project: Project = known ?? { path, name: path.split("/").filter(Boolean).pop() ?? path };
|
||||
const resolved = resolveFastForward([project], presets, name, path);
|
||||
const resolved = resolveFastForward([project], presets, handle, path);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
const inRepo =
|
||||
|
||||
+17
-3
@@ -215,6 +215,20 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return fl?.sessionId ?? null;
|
||||
}
|
||||
|
||||
/** The session the operator actually picked — what `focusedLeafSession()`
|
||||
* would return with the preview undone. A preview moves the active tab and
|
||||
* its focus, but it is a view: the accent stays on the row the operator
|
||||
* chose until they choose another (docs/PREVIEW.md). */
|
||||
function realFocusedSession(): string | null {
|
||||
const p = preview;
|
||||
if (!p) return focusedLeafSession();
|
||||
const tab = layout.tabs.find((t) => t.id === p.prevActiveTabId) ?? activeTab(layout);
|
||||
const leafId =
|
||||
p.prevFocus && p.prevFocus.tabId === tab.id ? p.prevFocus.leafId : tab.focusedLeafId;
|
||||
const fl = leafId ? findLeaf(tab.root, leafId) : null;
|
||||
return fl?.sessionId ?? null;
|
||||
}
|
||||
|
||||
/** Update focus styling + pool focus + sidebar highlights without rebuilding
|
||||
* the DOM (cheap path for click-to-focus). */
|
||||
function syncFocus(): void {
|
||||
@@ -233,7 +247,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
if (!preview) pool.setFocus(focusedLeafSession());
|
||||
sidebar.setHighlights({
|
||||
open: realOpenSessions(),
|
||||
focused: focusedLeafSession(),
|
||||
focused: realFocusedSession(),
|
||||
});
|
||||
// The row the preview came from wears the same colour, so the sidebar and
|
||||
// the pane read as one thing rather than two unrelated highlights.
|
||||
@@ -608,7 +622,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
{ focus: !preview && !quietRender },
|
||||
);
|
||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: focused.sessionId ?? null });
|
||||
sidebar.setHighlights({ open: realOpenSessions(), focused: realFocusedSession() });
|
||||
sidebar.setPreview(preview?.sessionId ?? null);
|
||||
}
|
||||
|
||||
@@ -817,7 +831,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
openSessions: realOpenSessions,
|
||||
beginSessionDrag,
|
||||
render,
|
||||
focusedSessionId: focusedLeafSession,
|
||||
focusedSessionId: realFocusedSession,
|
||||
previewSession,
|
||||
previewedSessionId: () => preview?.sessionId ?? null,
|
||||
};
|
||||
|
||||
+33
-1
@@ -47,12 +47,20 @@ function fakePool(): Spy {
|
||||
|
||||
/** Last value the controller pushed to `SidebarHandle.setPreview`. */
|
||||
const previewRow: { id: string | null } = { id: null };
|
||||
/** Last highlights the controller pushed to `SidebarHandle.setHighlights`. */
|
||||
const rowHighlights: { open: Set<string>; focused: string | null } = {
|
||||
open: new Set(),
|
||||
focused: null,
|
||||
};
|
||||
|
||||
function fakeSidebar(): SidebarHandle {
|
||||
return {
|
||||
el: document.createElement("aside"),
|
||||
setActive() {},
|
||||
setHighlights() {},
|
||||
setHighlights(h) {
|
||||
rowHighlights.open = h.open;
|
||||
rowHighlights.focused = h.focused;
|
||||
},
|
||||
setPreview(id: string | null) {
|
||||
previewRow.id = id;
|
||||
},
|
||||
@@ -95,6 +103,8 @@ describe("sidebar preview", () => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
previewRow.id = null;
|
||||
rowHighlights.open = new Set();
|
||||
rowHighlights.focused = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -134,6 +144,28 @@ describe("sidebar preview", () => {
|
||||
expect(mv.el.querySelectorAll(".mv-pane.is-preview")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves the accent on the session the pane actually holds", () => {
|
||||
const layout = twoTabs();
|
||||
const spy = fakePool();
|
||||
const mv = makeMv(layout, spy.pool);
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
// Walking past other rows never moves the accent off what the operator
|
||||
// picked: only a real open does (docs/PREVIEW.md).
|
||||
mv.previewSession("s1");
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
expect(mv.focusedSessionId()).toBe("s2");
|
||||
|
||||
mv.previewSession("s9");
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
mv.previewSession(null);
|
||||
expect(rowHighlights.focused).toBe("s2");
|
||||
|
||||
mv.openSession("s1");
|
||||
expect(rowHighlights.focused).toBe("s1");
|
||||
});
|
||||
|
||||
it("shows a session no pane holds in a throwaway tab that leaves no trace", () => {
|
||||
const layout = twoTabs();
|
||||
const before = serializeLayout(layout);
|
||||
|
||||
+1
-4
@@ -6,10 +6,7 @@ import { escapeHtml } from "./html.js";
|
||||
export type Uuid = string;
|
||||
export type IsoTimestamp = string;
|
||||
|
||||
/** `Dormant` (#162) is a session the daemon knows only from its sidecar —
|
||||
* recovered after the tmux server died under it, so there is no pane behind it
|
||||
* and nothing to attach to. Listed, never live. */
|
||||
export type SessionStatus = "Running" | "Dead" | "Dormant";
|
||||
export type SessionStatus = "Running" | "Dead";
|
||||
|
||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||
|
||||
Reference in New Issue
Block a user