Compare commits

...
Author SHA1 Message Date
Miguel Palhas f60587a6d6 merge: sidebar-cards base into 112-detail-render 2026-08-24 22:22:09 +01:00
Miguel Palhas 43946c3c46 feat(maestro): render agent-reported detail on TUI session cards 2026-08-24 22:22:06 +01:00
Miguel Palhas c24a2133d4 feat(maestro): render agent-reported detail on web session cards 2026-08-24 22:13:59 +01:00
Miguel Palhas 75ccfe10d2 docs(maestro): pin detail truncation ahead of trailing meta fragments 2026-08-24 22:13:04 +01:00
Miguel Palhas ea3e18d1f2 merge: sidebar-cards/111-flaky-retention 2026-08-24 22:11:30 +01:00
Miguel Palhas 4f0e887a50 fix(maestro): parse reported activity states strictly 2026-08-24 22:05:40 +01:00
Miguel Palhas cfebfd441b fix(maestro): wait for retained-store write in natural-exit test
Dead status can be observed before the settle step's store write
lands, so the test's store read raced the write under parallel load.
Wait for the store to have the entry before asserting on it, same as
the settle-state wait a few lines below.

Fixes #111
2026-08-24 22:03:56 +01:00
Miguel Palhas 81312aa0c5 merge: sidebar-cards/110-lenient-activity 2026-08-24 22:02:04 +01:00
Miguel Palhas 52be48b0a6 merge: sidebar-cards/105-activity-endpoint 2026-08-24 22:01:42 +01:00
Miguel Palhas 202b5008b8 fix(maestro): drop an unused instant from report_activity 2026-08-24 21:40:34 +01:00
Miguel Palhas 015f2be5fb docs(maestro): record the activity-detail meta-line slot and report TTL 2026-08-24 21:39:15 +01:00
Miguel Palhas 5a594b2b7f feat(maestro): serve POST /api/sessions/:id/activity 2026-08-24 21:38:42 +01:00
Miguel Palhas 8370ddf066 feat(maestro): hold agent-reported activity above inference for a TTL 2026-08-24 21:36:56 +01:00
Miguel Palhas 5c4e083c2d feat(maestro): add activity_detail to Session and the activity-report wire types 2026-08-24 21:32:27 +01:00
21 changed files with 739 additions and 11 deletions
+1
View File
@@ -176,6 +176,7 @@ mod tests {
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
+59
View File
@@ -15,6 +15,44 @@ use maestro_protocol::ActivityState;
/// Active↔Idle. This window *is* the debounce the issue calls for.
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
/// How long an agent-reported state (#105) stays in force before inference
/// resumes. Long enough for a working agent's heartbeat to hold between
/// reports, short enough that an agent dying mid-run cannot pin its session
/// at `Error` forever.
pub const REPORT_TTL: Duration = Duration::from_secs(300);
/// Server-side cap (#105) on a reported `detail`, in characters. The meta
/// line renders it inline, so an unbounded string would own the whole card.
pub const MAX_REPORT_DETAIL_CHARS: usize = 256;
/// An agent-reported activity state (#105) still within its TTL. While one
/// is [`Self::in_force`], it outranks [`decide`]'s inference; when it
/// lapses, inference resumes and any reported detail is cleared with it.
#[derive(Debug, Clone)]
pub struct ReportedActivity {
pub state: ActivityState,
pub expires_at: std::time::Instant,
}
impl ReportedActivity {
pub fn new(state: ActivityState, ttl: Duration) -> Self {
Self {
state,
expires_at: std::time::Instant::now() + ttl,
}
}
pub fn in_force(&self, now: std::time::Instant) -> bool {
now < self.expires_at
}
}
/// Cap a reported detail to [`MAX_REPORT_DETAIL_CHARS`] characters,
/// char-boundary safe.
pub fn truncate_detail(detail: Option<String>) -> Option<String> {
detail.map(|d| d.chars().take(MAX_REPORT_DETAIL_CHARS).collect())
}
/// Combine the observed signals into an [`ActivityState`]:
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
/// else `Idle`.
@@ -59,4 +97,25 @@ mod tests {
ActivityState::Idle
);
}
#[test]
fn report_is_in_force_until_exactly_its_expiry() {
let ttl = Duration::from_secs(300);
let report = ReportedActivity::new(ActivityState::Error, ttl);
assert!(report.in_force(report.expires_at - Duration::from_millis(1)));
assert!(!report.in_force(report.expires_at));
}
#[test]
fn detail_truncates_to_the_cap_without_panic_on_multibyte_input() {
let long = "é".repeat(MAX_REPORT_DETAIL_CHARS + 10);
let capped = truncate_detail(Some(long)).unwrap();
assert_eq!(capped.chars().count(), MAX_REPORT_DETAIL_CHARS);
assert_eq!(truncate_detail(None), None);
assert_eq!(
truncate_detail(Some("short".into())).as_deref(),
Some("short")
);
}
}
+1
View File
@@ -27,5 +27,6 @@ pub mod tmux;
pub mod worktree;
pub mod worktree_probe;
pub use activity::MAX_REPORT_DETAIL_CHARS;
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
pub use session::{SessionError, SessionManager};
+27 -3
View File
@@ -15,12 +15,12 @@ use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::{any, delete, get};
use axum::routing::{any, delete, get, post};
use axum::{Json, Router};
use maestro_config::SpawnPreset;
use maestro_protocol::{
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
PROTOCOL_VERSION,
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
ReportActivityRequest, Session, PROTOCOL_VERSION,
};
use serde::Deserialize;
use uuid::Uuid;
@@ -46,6 +46,7 @@ pub struct LocalApiState {
pub fn router(state: LocalApiState) -> Router {
Router::new()
.route(routes::SESSIONS, get(list_sessions).post(create_session))
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
.route(routes::patterns::SESSION, delete(kill_session))
.route(routes::DIRS, get(list_dir))
.route(routes::RECENT_DIRS, get(recent_dirs))
@@ -318,6 +319,29 @@ async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>)
}
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
/// The report holds above the daemon's inference for a TTL, then inference
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
/// forever. Unknown session is 404; an unknown state value is rejected by the
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
async fn report_activity(
State(s): State<LocalApiState>,
Path(id): Path<Uuid>,
Json(body): Json<ReportActivityRequest>,
) -> Response {
match s.manager.report_activity(id, body.state, body.detail).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => {
let info = e.to_error_info();
error_response(
session_error_status(&info.code),
info.code.as_str(),
info.message.as_str(),
)
}
}
}
fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(status, Json(ApiError::new(code, message))).into_response()
}
+78 -1
View File
@@ -57,6 +57,11 @@ pub struct SessionRecord {
/// fall back to a probe.
pub worktree_branch: Option<String>,
pub bridge: PtyBridge,
/// Agent-reported activity (#105) still within its TTL. While in force it
/// outranks the activity pass's own inference; when it lapses, inference
/// resumes and [`Session::activity_detail`] is cleared with it. This
/// field is daemon-local bookkeeping only — it never rides the wire.
pub reported_activity: Option<crate::activity::ReportedActivity>,
}
#[derive(Debug, thiserror::Error)]
@@ -136,6 +141,9 @@ struct Inner {
/// the variable. A mutex because the runner sets it after construction,
/// possibly while clones are already serving.
daemon_url: std::sync::Mutex<Option<String>>,
/// TTL an agent-reported activity state (#105) stays in force. A const in
/// production; overridable for tests that watch the lapse happen.
report_ttl: Duration,
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
events: broadcast::Sender<CatalogEvent>,
}
@@ -176,6 +184,7 @@ impl SessionManager {
spawn_history: None,
retained: None,
daemon_url: std::sync::Mutex::new(None),
report_ttl: crate::activity::REPORT_TTL,
sessions: Mutex::new(HashMap::new()),
events,
}),
@@ -208,6 +217,15 @@ impl SessionManager {
self
}
/// Override the agent-report TTL (#105). Tests pass a short one to watch
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
let inner = Arc::get_mut(&mut self.inner)
.expect("with_activity_report_ttl must be called before the SessionManager is cloned");
inner.report_ttl = ttl;
self
}
/// Set the daemon's own HTTP base URL (#103), exported to every session
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
/// is a plain setter: the daemon runner learns its bound address after
@@ -379,6 +397,7 @@ impl SessionManager {
// activity watcher refines `foreground`/`activity` on its next tick.
foreground: None,
activity: ActivityState::Active,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
@@ -409,6 +428,7 @@ impl SessionManager {
worktree_base_repo,
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
bridge,
reported_activity: None,
},
);
}
@@ -602,6 +622,7 @@ impl SessionManager {
// recovered session had already been sitting idle.
foreground: None,
activity: ActivityState::Idle,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
@@ -635,6 +656,7 @@ impl SessionManager {
// recovered session probes git for it at retain time.
worktree_branch: None,
bridge,
reported_activity: None,
},
);
let _ = self
@@ -675,6 +697,47 @@ impl SessionManager {
.await
.map_err(SessionError::Tmux)
}
/// Accept an agent-reported activity state (#105), the only path that can
/// produce `ActivityState::Error`. The report outranks the activity pass's
/// own inference until its TTL lapses — so an agent that dies mid-run
/// cannot pin its session at `Error` forever. An unknown session is
/// [`SessionError::NotFound`]; the reported `detail` is capped
/// server-side.
///
/// A report that changes the effective state restarts `activity_since`
/// (#104's clock); re-reporting an unchanged state only extends the TTL
/// and stays silent on the event stream at steady state.
pub async fn report_activity(
&self,
id: Uuid,
state: ActivityState,
detail: Option<String>,
) -> Result<(), SessionError> {
let updated = {
let mut sessions = self.inner.sessions.lock().await;
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
let detail = crate::activity::truncate_detail(detail);
let changed = rec.session.activity != state || rec.session.activity_detail != detail;
if rec.session.activity != state {
rec.session.activity_since = Utc::now();
}
rec.session.activity = state;
rec.session.activity_detail = detail;
rec.reported_activity = Some(crate::activity::ReportedActivity::new(
state,
self.inner.report_ttl,
));
changed.then(|| rec.session.clone())
};
if let Some(session) = updated {
let _ = self
.inner
.events
.send(CatalogEvent::SessionUpdated { session });
}
Ok(())
}
}
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
@@ -845,8 +908,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
// #105: an agent report lapses at its TTL — drop it and its
// detail so this tick's inference takes over again.
if let Some(report) = &rec.reported_activity {
if !report.in_force(std::time::Instant::now()) {
rec.reported_activity = None;
rec.session.activity_detail = None;
}
}
let idle = rec.bridge.idle_for();
let activity = crate::activity::decide(idle, awaiting);
// #105: while a report is in force it supplies the state and
// nothing here may overwrite it; only an unreported session is
// decided by inference.
let activity = match &rec.reported_activity {
Some(report) => report.state,
None => crate::activity::decide(idle, awaiting),
};
let foreground = Some(pane.command.clone());
// The pane's live cwd, drives the dir-based session tree; empty means
// tmux reported none.
@@ -0,0 +1,164 @@
//! Agent-reported activity (#105): `POST /api/sessions/:id/activity`
//! semantics at the SessionManager level — precedence over the daemon's own
//! inference, detail handling, and lapse back to inference at the TTL.
mod common;
use std::sync::Arc;
use std::time::Duration;
use common::TmuxFixture;
use maestro_daemon::sidecar::SidecarStore;
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{ActivityState, CreateSessionRequest};
use tempfile::TempDir;
use tokio::time::sleep;
use uuid::Uuid;
struct Fixture {
manager: SessionManager,
_tmux: TmuxFixture,
_state_dir: TempDir,
}
async fn fixture(ttl: Duration) -> Fixture {
let tmux = TmuxFixture::new();
let state_dir = TempDir::new().unwrap();
let manager = SessionManager::new(
Arc::new(tmux.tmux.clone()),
SidecarStore::new(state_dir.path().join("sessions")),
WorktreeManager::new(None),
state_dir.path().join("fifos"),
)
.with_activity_report_ttl(ttl);
Fixture {
manager,
_tmux: tmux,
_state_dir: state_dir,
}
}
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
metadata: Default::default(),
worktree: None,
})
.await
.unwrap()
.id
}
async fn observed(manager: &SessionManager, id: Uuid) -> maestro_protocol::Session {
manager
.snapshot()
.await
.into_iter()
.find(|s| s.id == id)
.expect("session still tracked")
}
#[tokio::test]
async fn report_on_unknown_session_is_not_found() {
let fx = fixture(Duration::from_secs(300)).await;
let err = fx
.manager
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
.await
.unwrap_err();
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
}
#[tokio::test]
async fn report_sets_error_state_and_detail() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(
id,
ActivityState::Error,
Some("cargo build failed: link error".into()),
)
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::Error);
assert_eq!(
s.activity_detail.as_deref(),
Some("cargo build failed: link error")
);
}
#[tokio::test]
async fn replacing_a_report_replaces_both_state_and_detail() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager
.report_activity(id, ActivityState::Error, Some("first failure".into()))
.await
.unwrap();
fx.manager
.report_activity(
id,
ActivityState::AwaitingInput,
Some("needs approval".into()),
)
.await
.unwrap();
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::AwaitingInput);
assert_eq!(s.activity_detail.as_deref(), Some("needs approval"));
}
#[tokio::test]
async fn detail_is_capped_server_side() {
let fx = fixture(Duration::from_secs(300)).await;
let id = spawn_sleeping(&fx.manager).await;
let long = "x".repeat(10_000);
fx.manager
.report_activity(id, ActivityState::Error, Some(long))
.await
.unwrap();
let s = observed(&fx.manager, id).await;
let detail = s.activity_detail.expect("detail present");
assert_eq!(
detail.chars().count(),
maestro_daemon::MAX_REPORT_DETAIL_CHARS
);
}
#[tokio::test]
async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resumes() {
let fx = fixture(Duration::from_millis(150)).await;
let id = spawn_sleeping(&fx.manager).await;
fx.manager.start_liveness_watcher(Duration::from_millis(50));
fx.manager
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
.await
.unwrap();
// Several watcher ticks inside the TTL: nothing infers `Error`, so the
// report must hold above inference.
sleep(Duration::from_millis(80)).await;
let s = observed(&fx.manager, id).await;
assert_eq!(s.activity, ActivityState::Error);
assert_eq!(s.activity_detail.as_deref(), Some("agent died mid-run"));
// After the TTL lapses, inference resumes and the detail goes with it.
common::wait_for_async(Duration::from_secs(5), || async {
let s = observed(&fx.manager, id).await;
s.activity != ActivityState::Error && s.activity_detail.is_none()
})
.await;
}
+103
View File
@@ -346,6 +346,109 @@ async fn delete_owned_session_kills_it() {
assert!(!h.manager.has(session.id).await);
}
/// Spawn a session through the manager and return its id.
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
metadata: HashMap::new(),
worktree: None,
})
.await
.unwrap()
.id
}
/// The agent activity endpoint on the wire (#105): a report lands in the
/// session snapshot with its detail.
#[tokio::test]
async fn post_activity_reports_error_with_detail() {
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": "Error",
"detail": "cargo build failed"
}))
.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.activity, maestro_protocol::ActivityState::Error);
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
}
#[tokio::test]
async fn post_activity_to_unknown_session_returns_404() {
let h = spawn("naps-laptop").await;
let resp = reqwest::Client::new()
.post(url(
h.addr,
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
))
.json(&serde_json::json!({ "state": "Idle" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
}
/// Unknown state values are rejected rather than coerced (#105).
#[tokio::test]
async fn post_activity_rejects_an_unknown_state_value() {
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": "Teleported" }))
.send()
.await
.unwrap();
assert!(resp.status().is_client_error(), "got {}", resp.status());
}
#[tokio::test]
async fn post_activity_caps_an_overlong_detail_server_side() {
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": "Error",
"detail": "x".repeat(10_000)
}))
.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.activity_detail.as_ref().map(|d| d.chars().count()),
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
);
}
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
/// with the structured breakdown, and the session keeps running.
#[tokio::test]
@@ -769,6 +769,14 @@ async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
"a dirty worktree must be retained on natural exit"
);
// ...in the store, under the exit reason.
// (`Dead` is observable a moment before the settle step's store write lands.)
wait_for_async(Duration::from_secs(3), || async {
fx.retained_store()
.load_all()
.map(|all| all.len() == 1)
.unwrap_or(false)
})
.await;
let all = fx.retained_store().load_all().expect("load_all");
assert_eq!(all.len(), 1);
assert_eq!(all[0].path, wt_path);
+1
View File
@@ -26,6 +26,7 @@ fn sample(id: Uuid) -> Session {
.with_timezone(&Utc),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
+16 -1
View File
@@ -16,7 +16,7 @@ use uuid::Uuid;
use crate::routes::{self, UrlError};
use crate::{
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
KillOutcome, Project, RecentDirs, RetainedWorktree, Session,
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
};
/// Time budget for establishing a TCP connection to the daemon.
@@ -134,6 +134,21 @@ impl DaemonClient {
json(resp).await
}
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
/// state. The daemon holds it above its own inference for a TTL, then
/// resumes inference. Answers `204 No Content`.
pub async fn report_activity(
&self,
id: Uuid,
req: &ReportActivityRequest,
) -> Result<(), ClientError> {
let resp = self
.send(Method::POST, &routes::session_activity(id), &[], Some(req))
.await?;
check(resp).await?;
Ok(())
}
pub async fn daemon_info(&self) -> Result<DaemonInfo, ClientError> {
self.get(routes::INFO, &[]).await
}
+89
View File
@@ -75,6 +75,12 @@ pub struct Session {
/// byte stream.
#[serde(default)]
pub activity: ActivityState,
/// Short human-readable string an agent reported alongside its activity
/// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line
/// while the report is in force; cleared when the report lapses or is
/// replaced. `None` unless an agent reported one.
#[serde(default)]
pub activity_detail: Option<String>,
/// Wall-clock time of the session's most recent observed PTY output.
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
/// from daemons that predate the field.
@@ -152,6 +158,45 @@ impl<'de> Deserialize<'de> for ActivityState {
}
}
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
/// its own state. The daemon holds the reported state above its own inference
/// for a fixed TTL, then resumes inference.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReportActivityRequest {
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
/// degrades an unrecognised state to `Idle` instead of dropping the whole
/// session. A report travelling the other way has no such excuse: an agent
/// posting a state this daemon does not know is a bug in the agent, and
/// silently recording it as `Idle` would hide that behind a 204.
#[serde(deserialize_with = "strict_activity_state")]
pub state: ActivityState,
/// Short human string shown next to the state (e.g. an error message).
/// The daemon caps its length server-side.
#[serde(default)]
pub detail: Option<String>,
}
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let s = String::deserialize(deserializer)?;
match s.as_str() {
"Active" => Ok(ActivityState::Active),
"Idle" => Ok(ActivityState::Idle),
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
"Error" => Ok(ActivityState::Error),
other => Err(D::Error::unknown_variant(
other,
&["Active", "Idle", "AwaitingInput", "Error"],
)),
}
}
/// Optional worktree request attached to a `CreateSessionRequest`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorktreeSpec {
@@ -565,6 +610,7 @@ mod tests {
.with_timezone(&Utc),
foreground: Some("claude".into()),
activity: ActivityState::Active,
activity_detail: None,
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
.unwrap()
.with_timezone(&Utc),
@@ -689,6 +735,49 @@ mod tests {
assert!(serde_json::from_value::<Session>(json).is_err());
}
#[test]
fn activity_detail_defaults_to_none_on_legacy_payload() {
// #105: a daemon predating the detail field omits it; it must
// deserialize to `None` rather than failing, and a present value
// round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.activity_detail, None);
let mut reported = sample_session();
reported.activity = ActivityState::Error;
reported.activity_detail = Some("build failed".into());
assert_eq!(reported.clone(), round_trip(&reported));
}
#[test]
fn report_activity_request_defaults_detail_and_rejects_unknown_state() {
let bare: ReportActivityRequest =
serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap();
assert_eq!(bare.state, ActivityState::Error);
assert_eq!(bare.detail, None);
let full = ReportActivityRequest {
state: ActivityState::AwaitingInput,
detail: Some("waiting on permission".into()),
};
assert_eq!(full, round_trip(&full));
// Unknown state values are rejected rather than coerced (#105).
assert!(
serde_json::from_value::<ReportActivityRequest>(serde_json::json!({
"state": "Teleported"
}))
.is_err()
);
}
#[test]
fn session_round_trips() {
let s = sample_session();
+14
View File
@@ -47,6 +47,11 @@ pub fn session(id: Uuid) -> String {
format!("{SESSIONS}/{id}")
}
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
pub fn session_activity(id: Uuid) -> String {
format!("{SESSIONS}/{id}/activity")
}
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
pub fn worktree(id: Uuid) -> String {
format!("{WORKTREES}/{id}")
@@ -61,6 +66,7 @@ pub fn attach(id: Uuid) -> String {
/// registers the same paths [`session`] and [`attach`] build.
pub mod patterns {
pub const SESSION: &str = "/api/sessions/:id";
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
pub const ATTACH: &str = "/api/attach/:id";
pub const WORKTREE: &str = "/api/worktrees/:id";
}
@@ -169,6 +175,10 @@ mod tests {
session(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
);
assert_eq!(
session_activity(id()),
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
);
assert_eq!(
attach(id()),
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
@@ -191,6 +201,10 @@ mod tests {
patterns::SESSION.trim_end_matches(":id"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
session(id()).trim_end_matches(&id().to_string())
);
assert_eq!(
patterns::ATTACH.trim_end_matches(":id"),
attach(id()).trim_end_matches(&id().to_string())
+1
View File
@@ -41,6 +41,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
+2
View File
@@ -1126,6 +1126,7 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
@@ -1644,6 +1645,7 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
+1
View File
@@ -193,6 +193,7 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: chrono::Utc::now(),
activity_since: chrono::Utc::now(),
current_cwd: None,
+105 -2
View File
@@ -160,12 +160,26 @@ impl Sidebar {
spans.push(Span::styled(" ", base));
spans.push(Span::styled(format_state_duration(d), dot_style));
}
// The trailing fragments are measured up front so a long detail
// truncates ahead of them instead of pushing them off the line
// (docs/SIDEBAR.md).
let mut tail: Vec<Span<'static>> = Vec::new();
if let Some(b) = blocked {
spans.push(Span::styled(format!(" ⚠ {b}"), muted));
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
}
if dead {
spans.push(Span::styled(" exited", muted));
tail.push(Span::styled(" exited", muted));
}
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let budget = width.saturating_sub(head_w + tail_w);
spans.extend(fit_to_width(
vec![Span::styled(format!(" {detail}"), muted)],
budget,
));
}
spans.extend(tail);
Some(fit_to_width(spans, width))
}
}
@@ -271,6 +285,7 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
@@ -530,6 +545,93 @@ mod tests {
);
}
/// #112: the agent-reported detail sits right after the duration on the
/// meta line, in the muted style (docs/SIDEBAR.md).
#[test]
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
let mut s = session_named(1, "claude");
s.activity = ActivityState::Error;
s.activity_since = Utc::now() - Duration::seconds(90);
s.activity_detail = Some("cargo build failed".into());
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = &all[idx + 1];
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
assert!(
meta.contains("1m"),
"duration precedes the detail: {meta:?}"
);
assert!(
meta.contains("cargo build failed"),
"reported detail must render: {meta:?}"
);
}
/// A fresh Error report earns line 2 on its own (contract rule 4), with
/// the detail even though no duration has accrued.
#[test]
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
let mut s = session_named(1, "claude");
s.activity = ActivityState::Error;
s.activity_since = Utc::now() - Duration::seconds(5);
s.activity_detail = Some("waiting for approval".into());
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
assert!(
all[idx + 1].contains("waiting for approval"),
"detail must render on a fresh report's own meta line: {:?}",
all[idx + 1]
);
}
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
/// trailing fragments — a long detail never pushes blocked work or
/// `exited` off the line, and never wraps.
#[test]
fn long_detail_yields_to_blocked_work_and_exited() {
let mut s = session_named(1, "claude");
s.status = SessionStatus::Dead;
s.activity_since = Utc::now() - Duration::seconds(5);
s.activity_detail =
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
s.blocked_work = Some(InTheWay {
unpushed_commits: 2,
..InTheWay::default()
});
let sb = Sidebar {
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 40, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
assert!(
meta.contains("2 unpushed commits"),
"blocked work stays on the line: {meta:?}"
);
assert!(
meta.contains("exited"),
"exited fragment stays on the line: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
"meta line must not wrap: {:?}",
all[idx + 2]
);
}
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
/// `activity_since` and so contributes no duration — a card must not claim
/// the session has been idle since 1970. The dot still renders when some
@@ -579,6 +681,7 @@ mod tests {
created_at: Utc::now(),
foreground: None,
activity: Default::default(),
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: Utc::now(),
current_cwd: None,
+19 -4
View File
@@ -41,7 +41,7 @@ Otherwise line 2 is absent — never an empty placeholder row.
When present, line 2 reads left to right:
```
<dot> <duration> [<blocked work> | exited] [<command>]
<dot> <duration> [<detail>] [<blocked work> | exited] [<command>]
```
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
@@ -52,6 +52,14 @@ and duration are unconditional parts of line 2; the bracketed fragments appear
only when their trigger holds. A payload that predates #102 (no usable
`activity_since`) contributes no duration — the dot still renders.
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
`Error`. It sits right after the duration, in the muted style, and shows only
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
client ever lets it wrap.
`<command>` is optional and muted: the session's command line, shown only when
the rendered name came from `metadata.name` and differs from the command (the
web has always surfaced this; the TUI may omit it).
@@ -94,11 +102,18 @@ cutover points land within one tick.
## Precedence
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
the TTL the report carries; when the TTL expires, inference resumes. Clients
never blend the two: whichever source is in force supplies both the colour and
the clock for `activity_since`.
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
When the TTL expires, inference resumes and the session's reported detail —
if any — clears with the report. Clients never blend the two: whichever source
is in force supplies both the colour and the clock for `activity_since`.
If a client receives an `ActivityState` variant it does not know, it must not
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
until the daemon sends a variant it does know. Rust clients deserialize
activity leniently for the same reason.
Leniency runs one way only. It covers a client *reading* a newer daemon, where
dropping the whole session would be worse than dimming one card. A report
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
state is a 4xx: an agent posting a state the daemon does not know is a bug in
the agent, and answering 204 would hide it.
+32
View File
@@ -105,6 +105,38 @@ describe("blocked-work indicator (#83)", () => {
});
});
describe("agent-reported detail (#112)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
it("renders the reported detail right after the duration on line 2", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000020",
status: "Dead",
activity: "Error",
activity_since: ago(90_000),
activity_detail: "cargo build failed",
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
const order = [...line2!.children].map((c) => c.className);
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
expect(order.indexOf("activity-detail")).toBeLessThan(
order.findIndex((c) => c.startsWith("card-flag")),
);
});
it("omits the detail when no report is in force", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000021",
activity: "Active",
activity_since: ago(90_000),
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
});
});
describe("session cards (#107)", () => {
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
+3
View File
@@ -42,6 +42,9 @@ function metaLine(s: Session, now: number): string {
const fragments = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
+11
View File
@@ -851,6 +851,17 @@ textarea {
flex: 0 0 auto;
}
/* Agent-reported detail (#112): sits after the duration, shrinks with an
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
signals severity and "exited" dims with the row. */
.card-flag {
+4
View File
@@ -26,6 +26,10 @@ export interface Session {
foreground?: string | null;
/** Inferred activity. Defaults to `Idle` on the wire when omitted. */
activity?: ActivityState;
/** Short human string an agent reported alongside its state (#105) — e.g.
* why it is in `Error`. Shown on a sidebar card's meta line while the
* report is in force; cleared when the report lapses or is replaced. */
activity_detail?: string | null;
/** Wall-clock of the most recent observed PTY output (NAP-340). */
last_activity_at?: IsoTimestamp;
/** Wall-clock of when the session entered its current `activity` state