From 5c4e083c2d129e345ba86452be687213215ffb2b Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:32:27 +0100 Subject: [PATCH 1/5] feat(maestro): add activity_detail to Session and the activity-report wire types --- crates/maestro-cli/src/sessions.rs | 1 + crates/maestro-daemon/src/session.rs | 2 + crates/maestro-daemon/tests/sidecar.rs | 1 + crates/maestro-protocol/src/client.rs | 17 ++++++- crates/maestro-protocol/src/lib.rs | 62 ++++++++++++++++++++++++++ crates/maestro-protocol/src/routes.rs | 14 ++++++ crates/maestro-testing/src/lib.rs | 1 + crates/maestro-tui/src/app.rs | 2 + crates/maestro-tui/src/catalog.rs | 1 + crates/maestro-tui/src/sidebar.rs | 2 + 10 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/maestro-cli/src/sessions.rs b/crates/maestro-cli/src/sessions.rs index ed51946..12fa99b 100644 --- a/crates/maestro-cli/src/sessions.rs +++ b/crates/maestro-cli/src/sessions.rs @@ -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, diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 4070b24..82c1a94 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -379,6 +379,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(), @@ -602,6 +603,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(), diff --git a/crates/maestro-daemon/tests/sidecar.rs b/crates/maestro-daemon/tests/sidecar.rs index 2d88ff4..0274844 100644 --- a/crates/maestro-daemon/tests/sidecar.rs +++ b/crates/maestro-daemon/tests/sidecar.rs @@ -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")), diff --git a/crates/maestro-protocol/src/client.rs b/crates/maestro-protocol/src/client.rs index 61bcffd..f7d2dab 100644 --- a/crates/maestro-protocol/src/client.rs +++ b/crates/maestro-protocol/src/client.rs @@ -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 { self.get(routes::INFO, &[]).await } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index e960175..e1e7973 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -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, /// 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. @@ -131,6 +137,18 @@ pub enum ActivityState { Error, } +/// 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 { + 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, +} + /// Optional worktree request attached to a `CreateSessionRequest`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorktreeSpec { @@ -544,6 +562,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), @@ -639,6 +658,49 @@ mod tests { assert_eq!(ActivityState::default(), ActivityState::Idle); } + #[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::(serde_json::json!({ + "state": "Teleported" + })) + .is_err() + ); + } + #[test] fn session_round_trips() { let s = sample_session(); diff --git a/crates/maestro-protocol/src/routes.rs b/crates/maestro-protocol/src/routes.rs index 7b3e024..29671b5 100644 --- a/crates/maestro-protocol/src/routes.rs +++ b/crates/maestro-protocol/src/routes.rs @@ -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()) diff --git a/crates/maestro-testing/src/lib.rs b/crates/maestro-testing/src/lib.rs index d5a6d47..8defccf 100644 --- a/crates/maestro-testing/src/lib.rs +++ b/crates/maestro-testing/src/lib.rs @@ -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, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 3034aa4..245a628 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -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(), diff --git a/crates/maestro-tui/src/catalog.rs b/crates/maestro-tui/src/catalog.rs index 178a559..e3466f0 100644 --- a/crates/maestro-tui/src/catalog.rs +++ b/crates/maestro-tui/src/catalog.rs @@ -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, diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 4a02ca2..2737d12 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -271,6 +271,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, @@ -579,6 +580,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, From 8370ddf066fd81cada556d320d9beca7d0d1c9a5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:36:56 +0100 Subject: [PATCH 2/5] feat(maestro): hold agent-reported activity above inference for a TTL --- crates/maestro-daemon/src/activity.rs | 59 +++++++ crates/maestro-daemon/src/lib.rs | 1 + crates/maestro-daemon/src/session.rs | 78 ++++++++- .../maestro-daemon/tests/activity_report.rs | 164 ++++++++++++++++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 crates/maestro-daemon/tests/activity_report.rs diff --git a/crates/maestro-daemon/src/activity.rs b/crates/maestro-daemon/src/activity.rs index 7405bb5..5ece6ed 100644 --- a/crates/maestro-daemon/src/activity.rs +++ b/crates/maestro-daemon/src/activity.rs @@ -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) -> Option { + 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") + ); + } } diff --git a/crates/maestro-daemon/src/lib.rs b/crates/maestro-daemon/src/lib.rs index 8f85980..b92689e 100644 --- a/crates/maestro-daemon/src/lib.rs +++ b/crates/maestro-daemon/src/lib.rs @@ -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}; diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 82c1a94..0575760 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -57,6 +57,11 @@ pub struct SessionRecord { /// fall back to a probe. pub worktree_branch: Option, 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, } #[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>, + /// 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>, events: broadcast::Sender, } @@ -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 @@ -410,6 +428,7 @@ impl SessionManager { worktree_base_repo, worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()), bridge, + reported_activity: None, }, ); } @@ -637,6 +656,7 @@ impl SessionManager { // recovered session probes git for it at retain time. worktree_branch: None, bridge, + reported_activity: None, }, ); let _ = self @@ -677,6 +697,48 @@ 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, + ) -> Result<(), SessionError> { + let now = std::time::Instant::now(); + 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 @@ -847,8 +909,22 @@ async fn run_activity_pass(inner: &Arc) { 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. diff --git a/crates/maestro-daemon/tests/activity_report.rs b/crates/maestro-daemon/tests/activity_report.rs new file mode 100644 index 0000000..e494856 --- /dev/null +++ b/crates/maestro-daemon/tests/activity_report.rs @@ -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; +} From 5a594b2b7f45c995b98572668b4be56b739d64c5 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:38:42 +0100 Subject: [PATCH 3/5] feat(maestro): serve POST /api/sessions/:id/activity --- crates/maestro-daemon/src/local_api.rs | 30 ++++++- crates/maestro-daemon/tests/local_api.rs | 103 +++++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/maestro-daemon/src/local_api.rs b/crates/maestro-daemon/src/local_api.rs index 6ae1c3a..06c3100 100644 --- a/crates/maestro-daemon/src/local_api.rs +++ b/crates/maestro-daemon/src/local_api.rs @@ -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, Path(id): Path) } } +/// `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, + Path(id): Path, + Json(body): Json, +) -> 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() } diff --git a/crates/maestro-daemon/tests/local_api.rs b/crates/maestro-daemon/tests/local_api.rs index c5d1ab7..88a712b 100644 --- a/crates/maestro-daemon/tests/local_api.rs +++ b/crates/maestro-daemon/tests/local_api.rs @@ -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 = 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 = 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] From 015f2be5fb9c5a6da1df66adbb41a6c3b21a5694 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:39:15 +0100 Subject: [PATCH 4/5] docs(maestro): record the activity-detail meta-line slot and report TTL --- docs/SIDEBAR.md | 14 ++++++++++---- web/src/types.ts | 4 ++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 1ac2442..cab0444 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -41,7 +41,7 @@ Otherwise line 2 is absent — never an empty placeholder row. When present, line 2 reads left to right: ``` - [ | exited] [] + [] [ | exited] [] ``` `` is a single filled glyph (`●` on both clients) carrying the activity @@ -52,6 +52,11 @@ 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. +`` 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. + `` 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,9 +99,10 @@ 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) diff --git a/web/src/types.ts b/web/src/types.ts index c03aebd..3731779 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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 From 202b5008b865cedca54acd8ccc0807cbbec5ae22 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 21:40:34 +0100 Subject: [PATCH 5/5] fix(maestro): drop an unused instant from report_activity --- crates/maestro-daemon/src/session.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/maestro-daemon/src/session.rs b/crates/maestro-daemon/src/session.rs index 0575760..9ea3942 100644 --- a/crates/maestro-daemon/src/session.rs +++ b/crates/maestro-daemon/src/session.rs @@ -714,7 +714,6 @@ impl SessionManager { state: ActivityState, detail: Option, ) -> Result<(), SessionError> { - let now = std::time::Instant::now(); let updated = { let mut sessions = self.inner.sessions.lock().await; let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;