diff --git a/server/src/chat/transport/matrix/assign.rs b/server/src/chat/transport/matrix/assign.rs index f4008f51..8fc9b561 100644 --- a/server/src/chat/transport/matrix/assign.rs +++ b/server/src/chat/transport/matrix/assign.rs @@ -120,6 +120,10 @@ pub async fn handle_assign( return format!("Failed to assign model to **{story_name}**: {e}"); } + // Mirror the assignment into the CRDT register so the in-memory pipeline + // state stays consistent with the front-matter. + crate::crdt_state::set_agent(&story_id, Some(&agent_name)); + // Check whether a coder is already running on this story. let running_coders: Vec<_> = agents .list_agents() diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index 270d89b8..cd43b833 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -53,7 +53,7 @@ pub use types::{ }; pub use write::{ bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, - set_qa_mode, set_retry_count, write_item, + set_agent, set_qa_mode, set_retry_count, write_item, }; #[cfg(test)] diff --git a/server/src/crdt_state/write.rs b/server/src/crdt_state/write.rs index b5e9f1cf..5bc05f26 100644 --- a/server/src/crdt_state/write.rs +++ b/server/src/crdt_state/write.rs @@ -186,6 +186,36 @@ pub fn migrate_names_from_slugs() { slog!("[crdt] Migrated names for {count} items from story ID slugs"); } +/// Set the `agent` field for a pipeline item by its story ID. +/// +/// `Some(name)` writes the agent name into the CRDT register. +/// `None` clears the register by writing an empty string — use this +/// to unpin an agent without touching the surrounding item. +/// +/// This is the typed setter counterpart to [`write_item`]'s `agent` parameter. +/// Callers that only need to update the agent (e.g. the `update_story` MCP tool +/// and the Matrix `!assign` command) should prefer this function over +/// passing `agent` through the full [`write_item`] call, which requires all +/// other fields to be known. +/// +/// Returns `true` if the item was found and the write was performed. +pub fn set_agent(story_id: &str, agent: Option<&str>) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.index.get(story_id) else { + return false; + }; + let value = agent.unwrap_or("").to_string(); + apply_and_persist(&mut state, |s| { + s.crdt.doc.items[idx].agent.set(value.clone()) + }); + true +} + /// Set the typed `qa_mode` CRDT register for a pipeline item. /// /// Passing `Some(mode)` writes the mode string (e.g. `"server"`, `"agent"`, `"human"`) @@ -685,6 +715,79 @@ mod tests { migrate_names_from_slugs(); } + // ── set_agent tests ────────────────────────────────────────────────────── + + #[test] + fn set_agent_some_writes_name() { + init_for_test(); + + write_item( + "871_story_set_agent_write", + "2_current", + Some("Set Agent Write"), + None, + None, + None, + None, + None, + None, + None, + ); + + let found = set_agent("871_story_set_agent_write", Some("coder-1")); + assert!(found, "set_agent should return true for an existing item"); + + let item = read_item("871_story_set_agent_write").expect("item must exist"); + assert_eq!( + item.agent.as_deref(), + Some("coder-1"), + "agent should be written to CRDT register" + ); + } + + #[test] + fn set_agent_none_clears_register() { + init_for_test(); + + write_item( + "871_story_set_agent_clear", + "2_current", + Some("Set Agent Clear"), + Some("coder-2"), + None, + None, + None, + None, + None, + None, + ); + + // Confirm agent is set. + let before = read_item("871_story_set_agent_clear").expect("item must exist"); + assert_eq!(before.agent.as_deref(), Some("coder-2")); + + // Clear it. + let found = set_agent("871_story_set_agent_clear", None); + assert!(found, "set_agent should return true for an existing item"); + + let after = read_item("871_story_set_agent_clear").expect("item must exist"); + assert!( + after.agent.as_deref().unwrap_or("").is_empty(), + "agent should be cleared (empty string) after set_agent(None)" + ); + } + + #[test] + fn set_agent_returns_false_for_unknown_story() { + init_for_test(); + + let found = set_agent("999_story_nonexistent", Some("coder-1")); + assert!( + !found, + "set_agent should return false when story is not in the CRDT" + ); + } + // ── set_qa_mode regression tests ───────────────────────────────────────── #[test] diff --git a/server/src/http/mcp/story_tools/story/update.rs b/server/src/http/mcp/story_tools/story/update.rs index 20f8abc0..a62692a0 100644 --- a/server/src/http/mcp/story_tools/story/update.rs +++ b/server/src/http/mcp/story_tools/story/update.rs @@ -44,6 +44,19 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result Result