Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions crates/daemon/tests/status_time.rs
Original file line number Diff line number Diff line change
@@ -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<dyn Persister> = 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::<str>::from(Uuid::new_v4().to_string().as_str()),
None,
vec![Arc::<str>::from("test")],
Uuid::new_v4(),
chrono::Utc::now().to_rfc3339(),
);
CommandContext::new(
Arc::<str>::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::<Session>(&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());
}
50 changes: 45 additions & 5 deletions crates/entities/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<String>,

/// Human-facing session name. For Claude Code this is the operator's
Expand Down Expand Up @@ -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<i64>,
Expand Down Expand Up @@ -302,6 +303,45 @@ pub struct Session {
pub identity: Option<String>,
}

/// 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<Arc<str>>,
}

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:
Expand Down
12 changes: 11 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading