diff --git a/crates/daemon/tests/status_time.rs b/crates/daemon/tests/status_time.rs new file mode 100644 index 00000000..36fdc00c --- /dev/null +++ b/crates/daemon/tests/status_time.rs @@ -0,0 +1,125 @@ +//! MUS-113: `SetSessionCurrentTask` stamps `current_task_at` itself, so a client that sets a +//! status without a time (the TypeScript plugins) can't leave an older time beside it. + +use std::sync::Arc; + +use hyphae::{Gettable, Materialize}; +use muster_entities::{Session, SessionId, SetSessionCurrentTask}; +use myko::{ + command::{CommandContext, CommandHandler}, + core::item::Eventable, + request::RequestContext, + server::{MykoServerContext, Persister}, + wire::{MEvent, MEventType}, +}; +use myko_server::{BlackholePersister, MykoServer}; +use uuid::Uuid; + +fn setup() -> MykoServerContext { + muster_entities::link(); + daemon::link(); + let blackhole: Arc = Arc::new(BlackholePersister); + let server = MykoServer::builder() + .with_default_persister(blackhole) + .build(); + let ctx = server.ctx(); + let _: &'static MykoServer = Box::leak(Box::new(server)); + ctx +} + +fn session(id: &str) -> Session { + Session { + id: SessionId(Arc::from(id)), + client_id: None, + pid: 0, + cwd: "/repo".into(), + git_branch: None, + current_task: Some("day 1".into()), + session_name: None, + activity: None, + kind: None, + connected_at: 100, + last_activity_at: None, + last_tool: None, + last_tool_at: None, + // Set hours ago, with the status it described. + current_task_at: Some(1_000), + operator: None, + host: None, + project: None, + channels_enabled: None, + acks_pushes: None, + harness: None, + identity: None, + } +} + +fn cmd_ctx(ctx: &MykoServerContext) -> CommandContext { + let req = RequestContext::new( + Arc::::from(Uuid::new_v4().to_string().as_str()), + None, + vec![Arc::::from("test")], + Uuid::new_v4(), + chrono::Utc::now().to_rfc3339(), + ); + CommandContext::new( + Arc::::from("SetSessionCurrentTask"), + Arc::new(req), + Arc::new(ctx.clone()), + ) +} + +fn only_session(ctx: &MykoServerContext) -> Session { + let store = ctx + .registry + .get(Session::ENTITY_NAME_STATIC) + .expect("Session store exists"); + let entries = store.entries().materialize().get(); + assert_eq!(entries.len(), 1); + myko::utils::downcast_item::(&entries.into_iter().next().unwrap().1) + .expect("entry is a Session") +} + +#[test] +fn a_status_set_without_a_time_is_stamped_now_and_a_cleared_one_has_none() { + let ctx = setup(); + let event = MEvent::from_item(&session("s1"), MEventType::SET, &Uuid::new_v4().to_string()); + ctx.apply_event_batch(vec![event]) + .expect("apply Session SET"); + + // What a TypeScript plugin sends: the status, and no time. + let wire = serde_json::json!({"id": "s1", "currentTask": "MUS-113"}); + let cmd: SetSessionCurrentTask = serde_json::from_value(wire).expect("the plugins' shape"); + let before = chrono::Utc::now().timestamp_millis(); + cmd.execute(cmd_ctx(&ctx)).expect("set the status"); + let after = chrono::Utc::now().timestamp_millis(); + let s = only_session(&ctx); + assert_eq!(s.current_task.as_deref(), Some("MUS-113")); + let at = s.current_task_at.expect("stamped"); + assert!( + (before..=after).contains(&at), + "the old time {at} was left beside the new status" + ); + + SetSessionCurrentTask { + id: SessionId(Arc::from("s1")), + current_task: None, + } + .execute(cmd_ctx(&ctx)) + .expect("clear the status"); + let s = only_session(&ctx); + assert_eq!(s.current_task, None); + assert_eq!(s.current_task_at, None, "no status, no time"); + assert_eq!(s.cwd, "/repo", "the rest of the row is kept"); +} + +#[test] +fn an_unknown_session_is_refused() { + let ctx = setup(); + let err = SetSessionCurrentTask { + id: SessionId(Arc::from("nobody")), + current_task: Some("x".into()), + } + .execute(cmd_ctx(&ctx)); + assert!(err.is_err()); +} diff --git a/crates/entities/src/session.rs b/crates/entities/src/session.rs index 63d12bda..e01a3ce1 100644 --- a/crates/entities/src/session.rs +++ b/crates/entities/src/session.rs @@ -2,9 +2,9 @@ use std::sync::Arc; use myko::prelude::{EventPublishing as _, Querying as _, RegistryScoped as _, RequestScoped as _}; use myko::{ - command::{CommandContext, CommandError}, + command::{CommandContext, CommandError, CommandHandler}, entities::client::ClientId, - myko_item, + myko_command, myko_item, }; use serde::{Deserialize, Serialize}; @@ -161,9 +161,9 @@ pub struct Session { /// Free-form short status text, settable via the `set_status` command. /// Surfaces on the roster so peers can see what this session is up to - /// without needing a separate task system. + /// without needing a separate task system. Set by [`SetSessionCurrentTask`], which also sets + /// `current_task_at`. #[serde(default, skip_serializing_if = "Option::is_none")] - #[myko_setter] pub current_task: Option, /// Human-facing session name. For Claude Code this is the operator's @@ -234,7 +234,8 @@ pub struct Session { /// Wall-clock millis when `current_task` was last set, so a reader can /// tell a fresh status from one left over from hours ago (rally log ยง1.5). - /// `None` from a shim that predates it, or before any status was set. + /// `None` before any status was set, and while none is. [`SetSessionCurrentTask`] stamps it + /// with the daemon's clock (MUS-113), and the shim's `SetSessionCurrentTaskAt` may follow it. #[serde(default, skip_serializing_if = "Option::is_none")] #[myko_setter] pub current_task_at: Option, @@ -302,6 +303,45 @@ pub struct Session { pub identity: Option, } +/// Set a session's status, `current_task`, and stamp `current_task_at` with the daemon's clock: +/// now when there's a status, `None` when it's cleared (MUS-113). This replaces the +/// `#[myko_setter]` that wrote `current_task` alone, under the same name and wire shape +/// (`{id, currentTask}`), so every client keeps working. A client that sets the status without a +/// time, such as the TypeScript plugins, then can't leave an older time beside the new status, +/// which would make a fresh status read as old. The shim may still follow it with +/// `SetSessionCurrentTaskAt`, its own clock's stamp, which an older daemon needs. +#[myko_command] +pub struct SetSessionCurrentTask { + pub id: SessionId, + pub current_task: Option>, +} + +impl CommandHandler for SetSessionCurrentTask { + fn execute(self, ctx: CommandContext) -> Result<(), CommandError> { + let session = ctx + .exec_report(GetSessionById { + id: self.id.clone(), + })? + .ok_or_else(|| { + CommandError::new( + ctx.tx(), + ctx.command_id.to_string(), + format!("Session {} not found", self.id), + ) + })?; + let current_task = self.current_task.as_deref().map(str::to_owned); + let current_task_at = current_task + .as_ref() + .map(|_| chrono::Utc::now().timestamp_millis()); + ctx.emit_set(&Session { + current_task, + current_task_at, + ..session.as_ref().clone() + })?; + Ok(()) + } +} + /// Resolve the session a write command is acting *as*. /// /// Two caller-identity paths converge here: diff --git a/docs/roadmap.md b/docs/roadmap.md index befb3d81..ae4447bd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -573,7 +573,17 @@ Merged and needing a live run: MUS-36 and MUS-37. Not done: nudging a session whose status is old while it keeps calling tools; the daemon stamping the time itself rather than trusting the shim's clock; the TypeScript plugins setting it (their sessions show no age); and setting the status and its time in one atomic command - rather than two. + rather than two. (MUS-113 does the last three for any client the daemon serves.) +- [x] **MUS-113: A status set without a time doesn't keep the old one.** Found by the watchman + reading MUS-101: a TypeScript plugin sets `current_task` with `SetSessionCurrentTask` alone, + so the `current_task_at` an earlier status left stayed beside the new one, and a fresh status + read as hours old. Done: `SetSessionCurrentTask` is written by hand in place of the + `#[myko_setter]`, under the same name and wire shape (`{id, currentTask}`), and stamps + `current_task_at` with the daemon's clock as it sets the status, or clears it with the status. + So every client, the plugins included, gets a true age from one command. The shim still sends + `SetSessionCurrentTaskAt` after it, which an older daemon needs; on a new daemon it only + replaces the stamp with the shim's clock's. A test in `crates/daemon/tests/status_time.rs` + sends the plugins' shape over a stale time and checks it's replaced, and that a clear leaves none. - [x] **MUS-104: A waiter stands down when its session's id changes.** Reported by cobalt-bream on 2026-11-06: a ghost row, mellow-weevil (64a05eda, pid 0), stayed on the roster, and the daemon logged "64a05eda may take over from 02f2a191" every 3 s. One Claude process (3766675)