Compare commits

...
Author SHA1 Message Date
Miguel Palhas c004b5d1b6 feat(maestro): prune sidecars whose cwd no longer exists
recover()'s dormant-registration pass (#162) now deletes a sidecar
instead of registering it dormant when the directory it points at is
gone. Nothing can be resumed there, so the honest move is to remove
the record rather than keep an unactionable one around (#163).

Sidecars whose cwd still exists are unaffected, regardless of age.
2026-09-04 08:30:39 +01:00
Miguel Palhas c51d5b8f7b merge: persist agent session id (#161)
Closes #161
2026-09-04 08:27:49 +01:00
Miguel Palhas 1b2b392710 merge: dormant session recovery (#162)
Closes #162
2026-09-04 08:25:44 +01:00
Miguel PalhasandClaude Opus 5 72644dd564 feat(maestro): register dormant sessions on recovery
A host reboot kills the tmux server, so `SessionManager::recover` iterated an
empty list and every session vanished from the API while its sidecar sat
untouched on disk. A second recovery pass now reads the sidecars tmux had
nothing to adopt and registers each as `SessionStatus::Dormant` — a record of
what was running, with no pane and no PTY bridge behind it.

`SessionStatus` deserializes leniently now, like `ActivityState`: an unknown
spelling reads as `Dead` rather than failing the whole `Session`.

Refs #162

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:22:52 +01:00
Miguel Palhas fa846000e7 feat(maestro): persist the agent's own session id on the sidecar (#161)
The Claude Code hook already receives a session_id in every payload, but
the activity route dropped it and the daemon had no field to store it on.
Parse it out of the hook body, add Session.agent_session_id, and write it
through to the sidecar once — the first report that carries one wins, and
later reports never overwrite it since an agent's own id never changes
mid-session.

This is the input to a follow-up POST /api/sessions/:id/resume.
2026-09-04 08:22:51 +01:00
24 changed files with 712 additions and 45 deletions
+2
View File
@@ -188,6 +188,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
let status = match s.status { let status = match s.status {
SessionStatus::Running => "running", SessionStatus::Running => "running",
SessionStatus::Dead => "dead", SessionStatus::Dead => "dead",
SessionStatus::Dormant => "dormant",
}; };
let activity = match s.activity { let activity = match s.activity {
ActivityState::Active => "active", ActivityState::Active => "active",
@@ -252,6 +253,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
+5 -1
View File
@@ -307,7 +307,11 @@ async fn report_activity(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<ReportActivityRequest>, Json(body): Json<ReportActivityRequest>,
) -> Response { ) -> Response {
match s.manager.report_activity(id, body.state, body.detail).await { match s
.manager
.report_activity(id, body.state, body.detail, body.session_id)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(), Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => { Err(e) => {
let info = e.to_error_info(); let info = e.to_error_info();
+153 -23
View File
@@ -62,7 +62,10 @@ pub struct SessionRecord {
/// `base_repo` from the original `WorktreeSpec`. Needed because `git /// `base_repo` from the original `WorktreeSpec`. Needed because `git
/// worktree remove` is invoked from the base repo. /// worktree remove` is invoked from the base repo.
pub worktree_base_repo: Option<PathBuf>, pub worktree_base_repo: Option<PathBuf>,
pub bridge: PtyBridge, /// 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>,
/// Agent-reported activity (#105) still within its TTL. While in force it /// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference /// outranks the activity pass's own inference; when it lapses, inference
/// resumes and [`Session::activity_detail`] is cleared with it. This /// resumes and [`Session::activity_detail`] is cleared with it. This
@@ -291,7 +294,10 @@ impl SessionManager {
/// unknown to this daemon. /// unknown to this daemon.
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> { pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
let sessions = self.inner.sessions.lock().await; let sessions = self.inner.sessions.lock().await;
sessions.get(&id).map(|r| r.bridge.subscribe()) sessions
.get(&id)
.and_then(|r| r.bridge.as_ref())
.map(|b| b.subscribe())
} }
/// Subscribe to a session's PTY broadcast and return the replay buffer /// Subscribe to a session's PTY broadcast and return the replay buffer
@@ -310,19 +316,26 @@ impl SessionManager {
let (result, updated) = { let (result, updated) = {
let mut sessions = self.inner.sessions.lock().await; let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id)?; let rec = sessions.get_mut(&id)?;
rec.session.last_focused_at = Some(Utc::now()); let SessionRecord {
let updated = rec.session.clone(); 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();
// For a live session, tmux's own screen beats the byte tail: the // 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) // tail starts mid-stream, so a full-screen program (an agent TUI)
// replayed from it renders as garbage until something forces a // replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it // repaint. A dead session has no pane left to capture, so it
// keeps the byte tail. // keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running; let alive = session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held, // The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip. // which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in // That only happens on attach, and the alternative is a seam in
// the stream. // the stream.
let result = rec.bridge.subscribe_with_snapshot(|| { let result = bridge.subscribe_with_snapshot(|| {
if !alive { if !alive {
return None; return None;
} }
@@ -478,6 +491,7 @@ impl SessionManager {
// fresh tree has nothing in the way anyway. // fresh tree has nothing in the way anyway.
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}; };
if let Err(e) = self.inner.sidecar.write(&session) { if let Err(e) = self.inner.sidecar.write(&session) {
@@ -499,7 +513,7 @@ impl SessionManager {
fifo_path, fifo_path,
worktree_path, worktree_path,
worktree_base_repo, worktree_base_repo,
bridge, bridge: Some(bridge),
reported_activity: None, reported_activity: None,
settled: false, settled: false,
#[cfg(feature = "haiku-autoname")] #[cfg(feature = "haiku-autoname")]
@@ -683,6 +697,7 @@ impl SessionManager {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}, },
}; };
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo")); let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -707,7 +722,7 @@ impl SessionManager {
// directory blocks that branch forever (#54 review). // directory blocks that branch forever (#54 review).
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()), worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()), worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
bridge, bridge: Some(bridge),
reported_activity: None, reported_activity: None,
settled: false, settled: false,
#[cfg(feature = "haiku-autoname")] #[cfg(feature = "haiku-autoname")]
@@ -722,16 +737,105 @@ impl SessionManager {
.send(CatalogEvent::SessionAdded { session }); .send(CatalogEvent::SessionAdded { session });
adopted += 1; adopted += 1;
} }
self.register_dormant().await;
Ok(adopted) Ok(adopted)
} }
/// Forward keystrokes to a session's pane. A dead session is read-only — /// Second recovery pass (#162): register every sidecar tmux had nothing to
/// its pane is gone, so this is a no-op rather than a tmux failure the /// adopt as a [`SessionStatus::Dormant`] session.
/// client would have to interpret. ///
/// 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.
async fn register_dormant(&self) {
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 !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.
pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> { pub async fn send_keys(&self, id: Uuid, bytes: &[u8]) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -744,13 +848,15 @@ impl SessionManager {
/// Type `text` into a session's pane, with an Enter after it when /// Type `text` into a session's pane, with an Enter after it when
/// `submit` is set. /// `submit` is set.
/// ///
/// Unlike [`Self::send_keys`], a dead session is an error: the caller /// Unlike [`Self::send_keys`], a dead or dormant session is an error: the
/// asked for text to land somewhere, and a silent success would let a /// caller asked for text to land somewhere, and a silent success would let
/// script believe an agent had been told something it never heard. /// 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> { pub async fn send_input(&self, id: Uuid, text: &str, submit: bool) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Err(SessionError::NotRunning(id)), Some(SessionStatus::Dead | SessionStatus::Dormant) => {
return Err(SessionError::NotRunning(id))
}
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -768,12 +874,12 @@ impl SessionManager {
Ok(()) Ok(())
} }
/// Resize a session's window. No-op for a dead session, as with /// Resize a session's window. No-op for a dead or dormant session, as with
/// [`Self::send_keys`]. /// [`Self::send_keys`].
pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> { pub async fn resize(&self, id: Uuid, cols: u16, rows: u16) -> Result<(), SessionError> {
match self.status(id).await { match self.status(id).await {
None => return Err(SessionError::NotFound(id)), None => return Err(SessionError::NotFound(id)),
Some(SessionStatus::Dead) => return Ok(()), Some(SessionStatus::Dead | SessionStatus::Dormant) => return Ok(()),
Some(SessionStatus::Running) => {} Some(SessionStatus::Running) => {}
} }
let tmux = self.inner.tmux.clone(); let tmux = self.inner.tmux.clone();
@@ -881,11 +987,18 @@ impl SessionManager {
/// A report that changes the effective state restarts `activity_since` /// A report that changes the effective state restarts `activity_since`
/// (#104's clock); re-reporting an unchanged state only extends the TTL /// (#104's clock); re-reporting an unchanged state only extends the TTL
/// and stays silent on the event stream at steady state. /// 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( pub async fn report_activity(
&self, &self,
id: Uuid, id: Uuid,
state: ActivityState, state: ActivityState,
detail: Option<String>, detail: Option<String>,
agent_session_id: Option<String>,
) -> Result<(), SessionError> { ) -> Result<(), SessionError> {
let updated = { let updated = {
let mut sessions = self.inner.sessions.lock().await; let mut sessions = self.inner.sessions.lock().await;
@@ -901,7 +1014,16 @@ impl SessionManager {
state, state,
self.inner.report_ttl, self.inner.report_ttl,
)); ));
changed.then(|| rec.session.clone()) 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())
}; };
if let Some(session) = updated { if let Some(session) = updated {
let _ = self let _ = self
@@ -1174,7 +1296,11 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
rec.session.activity_detail = None; rec.session.activity_detail = None;
} }
} }
let idle = rec.bridge.idle_for(); // 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;
};
// #105: while a report is in force it supplies the state and // #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is // nothing here may overwrite it; only an unreported session is
// decided by inference. // decided by inference.
@@ -1292,8 +1418,10 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// alone and stays attached to the dead record for a later forced dismissal /// alone and stays attached to the dead record for a later forced dismissal
/// (#81), which leaves it on disk too. /// (#81), which leaves it on disk too.
/// ///
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`] /// A dead session does not come back as dead across a daemon restart: its
/// adopts only what tmux still holds. /// sidecar survives, so [`SessionManager::recover`]'s second pass registers it
/// as [`SessionStatus::Dormant`] (#162) — accurate either way, since neither
/// has a pane behind it.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) { async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
let flipped = { let flipped = {
let mut sessions = inner.sessions.lock().await; let mut sessions = inner.sessions.lock().await;
@@ -1302,7 +1430,9 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
record.session.status = SessionStatus::Dead; record.session.status = SessionStatus::Dead;
// Release anyone attached; the scrollback survives for a later // Release anyone attached; the scrollback survives for a later
// attach. // attach.
record.bridge.seal(); if let Some(bridge) = record.bridge.as_mut() {
bridge.seal();
}
let session = record.session.clone(); let session = record.session.clone();
let worktree = record let worktree = record
.worktree_base_repo .worktree_base_repo
+67 -5
View File
@@ -18,6 +18,7 @@ use uuid::Uuid;
struct Fixture { struct Fixture {
manager: SessionManager, manager: SessionManager,
sidecar: SidecarStore,
_tmux: TmuxFixture, _tmux: TmuxFixture,
_state_dir: TempDir, _state_dir: TempDir,
} }
@@ -25,15 +26,17 @@ struct Fixture {
async fn fixture(ttl: Duration) -> Fixture { async fn fixture(ttl: Duration) -> Fixture {
let tmux = TmuxFixture::new(); let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap(); let state_dir = TempDir::new().unwrap();
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
let manager = SessionManager::new( let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()), Arc::new(tmux.tmux.clone()),
SidecarStore::new(state_dir.path().join("sessions")), sidecar.clone(),
WorktreeManager::new(None), WorktreeManager::new(None),
state_dir.path().join("fifos"), state_dir.path().join("fifos"),
) )
.with_activity_report_ttl(ttl); .with_activity_report_ttl(ttl);
Fixture { Fixture {
manager, manager,
sidecar,
_tmux: tmux, _tmux: tmux,
_state_dir: state_dir, _state_dir: state_dir,
} }
@@ -67,7 +70,7 @@ async fn report_on_unknown_session_is_not_found() {
let fx = fixture(Duration::from_secs(300)).await; let fx = fixture(Duration::from_secs(300)).await;
let err = fx let err = fx
.manager .manager
.report_activity(Uuid::new_v4(), ActivityState::Idle, None) .report_activity(Uuid::new_v4(), ActivityState::Idle, None, None)
.await .await
.unwrap_err(); .unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_))); assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
@@ -83,6 +86,7 @@ async fn report_sets_error_state_and_detail() {
id, id,
ActivityState::Error, ActivityState::Error,
Some("cargo build failed: link error".into()), Some("cargo build failed: link error".into()),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -101,7 +105,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
let id = spawn_sleeping(&fx.manager).await; let id = spawn_sleeping(&fx.manager).await;
fx.manager fx.manager
.report_activity(id, ActivityState::Error, Some("first failure".into())) .report_activity(id, ActivityState::Error, Some("first failure".into()), None)
.await .await
.unwrap(); .unwrap();
fx.manager fx.manager
@@ -109,6 +113,7 @@ async fn replacing_a_report_replaces_both_state_and_detail() {
id, id,
ActivityState::AwaitingInput, ActivityState::AwaitingInput,
Some("needs approval".into()), Some("needs approval".into()),
None,
) )
.await .await
.unwrap(); .unwrap();
@@ -125,7 +130,7 @@ async fn detail_is_capped_server_side() {
let long = "x".repeat(10_000); let long = "x".repeat(10_000);
fx.manager fx.manager
.report_activity(id, ActivityState::Error, Some(long)) .report_activity(id, ActivityState::Error, Some(long), None)
.await .await
.unwrap(); .unwrap();
@@ -144,7 +149,12 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
fx.manager.start_liveness_watcher(Duration::from_millis(50)); fx.manager.start_liveness_watcher(Duration::from_millis(50));
fx.manager fx.manager
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into())) .report_activity(
id,
ActivityState::Error,
Some("agent died mid-run".into()),
None,
)
.await .await
.unwrap(); .unwrap();
@@ -162,3 +172,55 @@ async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resu
}) })
.await; .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"));
}
+27
View File
@@ -526,6 +526,33 @@ async fn post_activity_reports_error_with_detail() {
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed")); 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] #[tokio::test]
async fn post_activity_to_unknown_session_returns_404() { async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await; let h = spawn("naps-laptop").await;
@@ -140,6 +140,203 @@ async fn recover_adopts_existing_tmux_sessions() {
assert_eq!(snap[0].command, "sleep 30"); 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);
}
/// #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 /// 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 /// writes must reach the replay buffer. Before the fix, `pipe-pane` was a
/// separate tmux invocation that ran after `new-session` + an async FIFO /// separate tmux invocation that ran after `new-session` + an async FIFO
+1
View File
@@ -32,6 +32,7 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")), current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: Some("conv-abc123".into()),
} }
} }
+10 -2
View File
@@ -84,15 +84,23 @@ if [ "$state" = "Prompt" ]; then
>/dev/null 2>&1 || true >/dev/null 2>&1 || true
exit 0 exit 0
fi fi
body="{\"state\":\"$state\"}" fields="\"state\":\"$state\""
# StopFailure payloads carry the outcome in "error". Only a plain enum # StopFailure payloads carry the outcome in "error". Only a plain enum
# spelling is reported as the detail — anything else (quotes, backslashes, # spelling is reported as the detail — anything else (quotes, backslashes,
# prose) would risk breaking the JSON body, and the spelling already names # prose) would risk breaking the JSON body, and the spelling already names
# the failure. # the failure.
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p') detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
if [ -n "$detail" ]; then if [ -n "$detail" ]; then
body="{\"state\":\"$state\",\"detail\":\"$detail\"}" fields="$fields,\"detail\":\"$detail\""
fi 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 \ curl -fsS --connect-timeout 1 --max-time 2 \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$body" \ -d "$body" \
+35
View File
@@ -128,6 +128,41 @@ fn script_reports_the_error_spelling_as_detail() {
assert!(request.contains(r#""detail":"rate_limit""#), "{request}"); 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 /// 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. /// one at all) must never produce a broken body — no detail is posted.
#[test] #[test]
+1
View File
@@ -284,6 +284,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
+70 -1
View File
@@ -116,6 +116,13 @@ pub struct Session {
/// sorts a never-focused session after every session that has one. /// sorts a never-focused session after every session that has one.
#[serde(default)] #[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>, 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. /// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -123,10 +130,38 @@ fn epoch() -> DateTime<Utc> {
DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp")
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] /// 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)]
pub enum SessionStatus { pub enum SessionStatus {
Running, Running,
Dead, 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 /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
@@ -184,6 +219,11 @@ pub struct ReportActivityRequest {
/// The daemon caps its length server-side. /// The daemon caps its length server-side.
#[serde(default)] #[serde(default)]
pub detail: Option<String>, 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 /// Reject an unknown [`ActivityState`] spelling instead of degrading it to
@@ -862,6 +902,7 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
@@ -1031,6 +1072,7 @@ mod tests {
let full = ReportActivityRequest { let full = ReportActivityRequest {
state: ActivityState::AwaitingInput, state: ActivityState::AwaitingInput,
detail: Some("waiting on permission".into()), detail: Some("waiting on permission".into()),
session_id: Some("conv-abc123".into()),
}; };
assert_eq!(full, round_trip(&full)); assert_eq!(full, round_trip(&full));
@@ -1059,6 +1101,33 @@ mod tests {
serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::to_value(SessionStatus::Dead).unwrap(),
serde_json::json!("Dead") 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] #[test]
+1
View File
@@ -47,6 +47,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
+6
View File
@@ -1898,6 +1898,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}) })
.collect(), .collect(),
}); });
@@ -2079,6 +2080,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}], }],
}); });
app.dispatch(Action::SidebarNext); app.dispatch(Action::SidebarNext);
@@ -2106,6 +2108,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
@@ -3214,6 +3217,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: focused, last_focused_at: focused,
agent_session_id: None,
}; };
let older = Utc::now() - chrono::Duration::seconds(60); let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot { app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3492,6 +3496,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}], }],
}); });
app.dispatch(Action::KillHighlighted); app.dispatch(Action::KillHighlighted);
@@ -3527,6 +3532,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}], }],
}); });
} }
+1
View File
@@ -223,6 +223,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
+22 -2
View File
@@ -194,7 +194,9 @@ pub enum BarOutcome {
pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> { pub fn order_sessions_for_bar(sessions: &[Session]) -> Vec<&Session> {
let mut ordered: Vec<&Session> = sessions.iter().collect(); let mut ordered: Vec<&Session> = sessions.iter().collect();
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let dead = |s: &Session| u8::from(s.status == SessionStatus::Dead); // 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);
dead(a) dead(a)
.cmp(&dead(b)) .cmp(&dead(b))
.then_with(|| { .then_with(|| {
@@ -319,7 +321,7 @@ impl CommandBar {
.map(|s| BarEntry { .map(|s| BarEntry {
candidate: MatchCandidate::Session(session_candidate(s, home_dir)), candidate: MatchCandidate::Session(session_candidate(s, home_dir)),
target: BarTarget::Session(s.id), target: BarTarget::Session(s.id),
dead: s.status == SessionStatus::Dead, dead: s.status != SessionStatus::Running,
chords: Vec::new(), chords: Vec::new(),
}) })
.collect(); .collect();
@@ -809,6 +811,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
@@ -889,6 +892,23 @@ mod tests {
assert!(bar.rows()[1].dead); 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] #[test]
fn typing_ranks_through_the_matcher_and_returns_ranges() { fn typing_ranks_through_the_matcher_and_returns_ranges() {
let mut bar = CommandBar::new( let mut bar = CommandBar::new(
+41
View File
@@ -268,6 +268,8 @@ impl Sidebar {
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> { fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let glyph = match (s.status, self.attached.contains(&s.id)) { let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗", (SessionStatus::Dead, _) => "✗",
// #162: known from its sidecar, nothing running behind it.
(SessionStatus::Dormant, _) => "◌",
(SessionStatus::Running, true) => "●", (SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○", (SessionStatus::Running, false) => "○",
}; };
@@ -322,6 +324,7 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics. // on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear()); let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead; let dead = s.status == SessionStatus::Dead;
let dormant = s.status == SessionStatus::Dormant;
let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let fill = self.card_fill(s, focused); let fill = self.card_fill(s, focused);
let base = card_base(fill, focused); let base = card_base(fill, focused);
@@ -361,6 +364,9 @@ impl Sidebar {
if dead { if dead {
tail.push(Span::styled(" exited", muted)); 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. // docs/SIDEBAR.md: the card names the program, never its arguments.
let command = Span::styled(format!(" {}", command_program(&s.command)), muted); let command = Span::styled(format!(" {}", command_program(&s.command)), muted);
@@ -500,6 +506,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
} }
} }
@@ -1068,6 +1075,39 @@ 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. /// docs/SIDEBAR.md: line 2 names the program, never its arguments.
#[test] #[test]
fn meta_line_drops_the_commands_arguments() { fn meta_line_drops_the_commands_arguments() {
@@ -1266,6 +1306,7 @@ mod tests {
current_cwd: None, current_cwd: None,
blocked_work: None, blocked_work: None,
last_focused_at: None, last_focused_at: None,
agent_session_id: None,
}; };
let sb = Sidebar { let sb = Sidebar {
sessions: vec![s], sessions: vec![s],
+3 -3
View File
@@ -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. `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**, **A session that has never been focused sorts after every session that has**,
and among those, most recently created first. Dead sessions sort after live and among those, most recently created first. Sessions with no live pane —
ones regardless of recency: a session you cannot attach to is not where you `Dead`, and `Dormant` (#162) — sort after live ones regardless of recency: a
just were. session you cannot attach to is not where you just were.
Under `>` an empty query lists every command in registry order. Under `~` it Under `>` an empty query lists every command in registry order. Under `~` it
lists recent dirs, most recent first, then the project list. lists recent dirs, most recent first, then the project list.
+20
View File
@@ -68,6 +68,26 @@ 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 moment a probe succeeds — there is no "retry" button, because the client is
already retrying. 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 ## Timing
- **Grace window** — 1200 ms between the socket dropping and the overlay - **Grace window** — 1200 ms between the socket dropping and the overlay
+10 -4
View File
@@ -56,7 +56,7 @@ itself is not.
Line 2 reads left to right: Line 2 reads left to right:
``` ```
[<duration>] [<detail>] <command> [<diff stat> | exited] [<duration>] [<detail>] <command> [<diff stat> | exited | dormant]
``` ```
Its minimum content is therefore just the command. Its minimum content is therefore just the command.
@@ -104,8 +104,14 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report 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 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 truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither pushes `<command>`, `<blocked work>`, `exited` or `dormant` off the line, and
client ever lets it wrap. 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.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing `<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
@@ -209,7 +215,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 highlighted **folder** row — a folder previews nothing, so it has no fill to
step up to. step up to.
The duration text inherits its state's colour; the exited and command The duration text inherits its state's colour; the exited, dormant and command
fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and 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 `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they
take the `active` and `error` roles of the same surface. take the `active` and `error` roles of the same surface.
+13
View File
@@ -414,6 +414,19 @@ describe("session cards (#107)", () => {
expect(line2?.textContent).toContain("exited"); 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", () => { it("carries the state word only in tooltips and aria labels, never as text", () => {
const s = session({ const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000019", id: "aaaaaaaa-0000-0000-0000-000000000019",
+4
View File
@@ -113,6 +113,9 @@ function activityRailMarkup(slug: string, stateWord: string): string {
function metaLine(s: Session, now: number): string { function metaLine(s: Session, now: number): string {
const dead = s.status === "Dead"; 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 blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle"); const slug = activityColorSlug(s.activity ?? "Idle");
const held = activityHeldMs(s.activity_since, now); const held = activityHeldMs(s.activity_since, now);
@@ -127,6 +130,7 @@ function metaLine(s: Session, now: number): string {
commandFragment(s.command), commandFragment(s.command),
blockers ? blockedFragment(blockers) : "", blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "", 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>`; return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
} }
+14
View File
@@ -99,6 +99,20 @@ 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", () => { it("treats a null last_focused_at as never focused", () => {
const nulled = session("nulled", { last_focused_at: null }); const nulled = session("nulled", { last_focused_at: null });
const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" }); const stamped = session("stamped", { last_focused_at: "2020-01-01T00:00:00Z" });
+5 -3
View File
@@ -142,8 +142,10 @@ function createdAt(s: Session): number {
* matcher's last tie-break preserves. */ * matcher's last tie-break preserves. */
export function orderSessionsForBar(sessions: readonly Session[]): Session[] { export function orderSessionsForBar(sessions: readonly Session[]): Session[] {
return [...sessions].sort((a, b) => { return [...sessions].sort((a, b) => {
const deadA = a.status === "Dead" ? 1 : 0; // docs/COMMAND-BAR.md: anything with no live pane — dead or dormant (#162)
const deadB = b.status === "Dead" ? 1 : 0; // — sorts after the sessions you could actually attach to.
const deadA = a.status === "Running" ? 0 : 1;
const deadB = b.status === "Running" ? 0 : 1;
if (deadA !== deadB) return deadA - deadB; if (deadA !== deadB) return deadA - deadB;
const focusA = focusedAt(a); const focusA = focusedAt(a);
@@ -350,7 +352,7 @@ function renderRow(result: BarResult, index: number): HTMLElement {
row.dataset.kind = candidate.kind; row.dataset.kind = candidate.kind;
if (candidate.kind === "session") { if (candidate.kind === "session") {
row.dataset.sessionId = candidate.session.id; row.dataset.sessionId = candidate.session.id;
if (candidate.session.status === "Dead") row.classList.add("is-dead"); if (candidate.session.status !== "Running") row.classList.add("is-dead");
} else if (isActionCandidate(candidate)) { } else if (isActionCandidate(candidate)) {
row.dataset.actionId = candidate.action.id; row.dataset.actionId = candidate.action.id;
} else if (candidate.kind === "command") { } else if (candidate.kind === "command") {
+4 -1
View File
@@ -6,7 +6,10 @@ import { escapeHtml } from "./html.js";
export type Uuid = string; export type Uuid = string;
export type IsoTimestamp = string; export type IsoTimestamp = string;
export type SessionStatus = "Running" | "Dead"; /** `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";
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340). /** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
* Absent on payloads from daemons that predate the field. `Error` is only set * Absent on payloads from daemons that predate the field. `Error` is only set