mark_session_dead_with_worktree_settlement cloned the session under the lock, dropped it, did async work, then wrote the stale clone to the sidecar. A haiku rename (or any other metadata update) that landed between the clone and the write was silently overwritten, so the sidecar reverted to name_source: Generated and the next daemon recovery re-triggered autoname. Re-read record.session under the lock at write time so the sidecar always reflects the latest state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
6.8 KiB
Rust
208 lines
6.8 KiB
Rust
//! Naming a session after its first prompt (#131): what the daemon lets a
|
|
//! naming run change, and what it protects. The naming run itself (`claude -p`)
|
|
//! is not invoked here — these cover the claim/apply rules around it.
|
|
#![cfg(feature = "haiku-autoname")]
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use common::TmuxFixture;
|
|
use maestro_daemon::sidecar::SidecarStore;
|
|
use maestro_daemon::worktree::WorktreeManager;
|
|
use maestro_daemon::SessionManager;
|
|
use maestro_protocol::{display, CatalogEvent, CreateSessionRequest, NameSource, SessionStatus};
|
|
use tempfile::TempDir;
|
|
use uuid::Uuid;
|
|
|
|
struct Fixture {
|
|
manager: SessionManager,
|
|
sidecar: SidecarStore,
|
|
tmux: TmuxFixture,
|
|
_state_dir: TempDir,
|
|
}
|
|
|
|
fn fixture() -> Fixture {
|
|
let tmux = TmuxFixture::new();
|
|
let state_dir = TempDir::new().unwrap();
|
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
|
let manager = SessionManager::new(
|
|
Arc::new(tmux.tmux.clone()),
|
|
sidecar.clone(),
|
|
WorktreeManager::new(None),
|
|
state_dir.path().join("fifos"),
|
|
);
|
|
Fixture {
|
|
manager,
|
|
sidecar,
|
|
tmux,
|
|
_state_dir: state_dir,
|
|
}
|
|
}
|
|
|
|
async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
|
|
let metadata = match name {
|
|
Some(n) => std::collections::HashMap::from([(
|
|
"name".to_string(),
|
|
serde_json::Value::String(n.to_string()),
|
|
)]),
|
|
None => Default::default(),
|
|
};
|
|
manager
|
|
.create(CreateSessionRequest {
|
|
view: Default::default(),
|
|
cwd: "/tmp".into(),
|
|
command: "sleep 60".into(),
|
|
env: Default::default(),
|
|
metadata,
|
|
worktree: None,
|
|
auto_approve: false,
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.id
|
|
}
|
|
|
|
async fn name_of(manager: &SessionManager, id: Uuid) -> Option<String> {
|
|
let snapshot = manager.snapshot().await;
|
|
let s = snapshot.into_iter().find(|s| s.id == id)?;
|
|
s.metadata
|
|
.get("name")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_prompt_for_an_unknown_session_is_not_found() {
|
|
let fx = fixture();
|
|
let err = fx.manager.claim_autoname(Uuid::new_v4()).await.unwrap_err();
|
|
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
|
}
|
|
|
|
/// The first prompt claims the session's one naming attempt; a second finds
|
|
/// it taken, so two prompts in a row cannot start two runs.
|
|
#[tokio::test]
|
|
async fn only_the_first_prompt_claims_the_naming_attempt() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
|
|
assert!(fx.manager.claim_autoname(id).await.unwrap());
|
|
assert!(!fx.manager.claim_autoname(id).await.unwrap());
|
|
}
|
|
|
|
/// A name a person chose is never claimed, so the naming run never starts for
|
|
/// it in the first place.
|
|
#[tokio::test]
|
|
async fn a_user_named_session_is_never_claimed() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, Some("amber-falcon")).await;
|
|
|
|
assert!(!fx.manager.claim_autoname(id).await.unwrap());
|
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("amber-falcon"),
|
|
"a chosen name survives a naming run that reports late"
|
|
);
|
|
}
|
|
|
|
/// The generated handle is replaced, the session settles at `Haiku`, and the
|
|
/// new name reaches memory, disk and the event stream (#131).
|
|
#[tokio::test]
|
|
async fn a_generated_handle_is_replaced_and_settles() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
let mut events = fx.manager.events();
|
|
fx.manager.claim_autoname(id).await.unwrap();
|
|
|
|
fx.manager
|
|
.apply_autoname(id, "sidebar activity rail")
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("sidebar activity rail")
|
|
);
|
|
let persisted = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(display::name_source(&persisted), NameSource::Haiku);
|
|
assert!(!display::is_provisional_name(&persisted));
|
|
|
|
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
|
|
.await
|
|
.expect("an update within the timeout")
|
|
.expect("the event channel stays open");
|
|
match event {
|
|
CatalogEvent::SessionUpdated { session } => {
|
|
assert_eq!(session.id, id);
|
|
assert_eq!(display::name_source(&session), NameSource::Haiku);
|
|
}
|
|
other => panic!("expected SessionUpdated, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// A rename that lands while the naming run is out wins: the run's answer is
|
|
/// dropped rather than overwriting what the person just typed (#131).
|
|
#[tokio::test]
|
|
async fn a_rename_during_the_run_beats_the_naming_result() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
fx.manager.claim_autoname(id).await.unwrap();
|
|
|
|
fx.manager.rename(id, "crimson-otter").await.unwrap();
|
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
|
|
assert_eq!(
|
|
name_of(&fx.manager, id).await.as_deref(),
|
|
Some("crimson-otter")
|
|
);
|
|
}
|
|
|
|
/// A naming run that produced nothing usable leaves the handle alone.
|
|
#[tokio::test]
|
|
async fn a_blank_name_changes_nothing() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
let before = name_of(&fx.manager, id).await;
|
|
|
|
fx.manager.apply_autoname(id, " ").await.unwrap();
|
|
|
|
assert_eq!(name_of(&fx.manager, id).await, before);
|
|
let live = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(display::name_source(&live), NameSource::Generated);
|
|
}
|
|
|
|
/// A session that dies naturally (liveness watcher marks it dead) must not
|
|
/// overwrite a haiku rename that landed before the death. Regression test for
|
|
/// the stale-clone sidecar write in `mark_session_dead`.
|
|
#[tokio::test]
|
|
async fn session_death_preserves_the_haiku_name() {
|
|
let fx = fixture();
|
|
let id = spawn(&fx.manager, None).await;
|
|
|
|
fx.manager.claim_autoname(id).await.unwrap();
|
|
fx.manager.apply_autoname(id, "haiku-name").await.unwrap();
|
|
|
|
// Verify the sidecar has Haiku before the session dies.
|
|
let before = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(display::name_source(&before), NameSource::Haiku);
|
|
|
|
// Kill the tmux session directly so the liveness watcher picks it up.
|
|
fx.tmux.tmux.kill_session(id).expect("kill tmux directly");
|
|
fx.manager
|
|
.start_liveness_watcher(std::time::Duration::from_millis(25));
|
|
|
|
common::wait_for_async(std::time::Duration::from_secs(3), || async {
|
|
fx.manager.status(id).await == Some(SessionStatus::Dead)
|
|
})
|
|
.await;
|
|
|
|
let after = fx.sidecar.read(id).unwrap().unwrap();
|
|
assert_eq!(
|
|
display::name_source(&after),
|
|
NameSource::Haiku,
|
|
"mark_session_dead must not overwrite a haiku rename in the sidecar"
|
|
);
|
|
assert_eq!(display::custom_name(&after), Some("haiku-name"));
|
|
}
|