Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c004b5d1b6 | ||
|
|
c51d5b8f7b | ||
|
|
1b2b392710 | ||
|
|
72644dd564 | ||
|
|
fa846000e7 |
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" \
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user