huskies: merge 877

This commit is contained in:
dave
2026-04-29 22:04:47 +00:00
parent 7e2f122d36
commit e56bd2d834
4 changed files with 260 additions and 63 deletions
+139
View File
@@ -0,0 +1,139 @@
//! Assign-and-start: unified service for pinning an agent to a story and starting it.
//!
//! Both the Matrix `assign` command and the MCP `start_agent` tool delegate
//! here when an explicit `agent_name` is provided, ensuring that the CRDT
//! `agent` register is always written before the agent is spawned. This
//! eliminates the asymmetry where the chat path wrote YAML + CRDT while the
//! MCP path only called `start_agent`.
use crate::agents::{AgentInfo, AgentPool};
use std::path::Path;
/// Pin an agent to a story in the CRDT and immediately start it.
///
/// Writes `agent_name` into the typed CRDT `agent` register for `story_id`,
/// then delegates to [`AgentPool::start_agent`] with the explicit name.
///
/// Callers that need to stop an already-running agent first (e.g. the Matrix
/// `assign` command replacing a running coder) must do so before calling this
/// function; the pool's concurrency guard will reject a duplicate start.
///
/// Returns the [`AgentInfo`] for the newly started agent, or an error string.
pub async fn assign_and_start(
story_id: &str,
agent_name: &str,
project_root: &Path,
agents: &AgentPool,
) -> Result<AgentInfo, String> {
crate::crdt_state::set_agent(story_id, Some(agent_name));
agents
.start_agent(project_root, story_id, Some(agent_name), None, None)
.await
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::agents::AgentPool;
use std::sync::Arc;
fn make_agents() -> Arc<AgentPool> {
Arc::new(AgentPool::new_test(3000))
}
/// Regression test (story 877, AC4): assigning via the Matrix chat path
/// and via the MCP `start_agent` path both call `assign_and_start`, which
/// writes the typed CRDT `agent` register before spawning. This test
/// verifies the CRDT write is unconditional even when the pool call fails
/// (no git repo in the test environment).
#[tokio::test]
async fn assign_and_start_writes_crdt_agent_register_before_pool_call() {
crate::crdt_state::init_for_test();
let story_id = "8770_story_assign_regression_crdt";
// Seed the CRDT so set_agent can find the item.
crate::crdt_state::write_item(
story_id,
"2_current",
Some("Assign Regression"),
None,
None,
None,
None,
None,
None,
None,
);
let tmp = tempfile::tempdir().unwrap();
let agents = make_agents();
// The pool call will fail (no git repo / project config), but the CRDT
// write must succeed before it is attempted.
let _ = assign_and_start(story_id, "coder-opus", tmp.path(), &agents).await;
let dump = crate::crdt_state::dump_crdt_state(Some(story_id));
let item = dump
.items
.iter()
.find(|i| i.story_id.as_deref() == Some(story_id))
.expect("item must be in CRDT");
assert_eq!(
item.agent.as_deref(),
Some("coder-opus"),
"CRDT agent register must be set to coder-opus after assign_and_start"
);
}
/// Both paths (Matrix and MCP) that call `assign_and_start` leave the same
/// CRDT register value, regardless of which path is used.
#[tokio::test]
async fn assign_and_start_same_crdt_state_from_both_paths() {
crate::crdt_state::init_for_test();
let story_id_a = "8771_story_assign_path_a";
let story_id_b = "8772_story_assign_path_b";
for sid in &[story_id_a, story_id_b] {
crate::crdt_state::write_item(
sid,
"2_current",
Some("Path Test"),
None,
None,
None,
None,
None,
None,
None,
);
}
let tmp = tempfile::tempdir().unwrap();
let agents = make_agents();
// Simulate Matrix path: call assign_and_start directly (as handle_assign does).
let _ = assign_and_start(story_id_a, "coder-sonnet", tmp.path(), &agents).await;
// Simulate MCP path: call assign_and_start directly (as tool_start_agent does).
let _ = assign_and_start(story_id_b, "coder-sonnet", tmp.path(), &agents).await;
// Both must leave the same CRDT agent register value.
for sid in &[story_id_a, story_id_b] {
let dump = crate::crdt_state::dump_crdt_state(Some(sid));
let item = dump
.items
.iter()
.find(|i| i.story_id.as_deref() == Some(*sid))
.expect("item must be in CRDT");
assert_eq!(
item.agent.as_deref(),
Some("coder-sonnet"),
"CRDT agent register must match for story {sid}"
);
}
}
}
+4
View File
@@ -1,5 +1,9 @@
//! Work-item service — cross-cutting domain logic that applies to all pipeline
//! work-item types (stories, bugs, spikes, refactors).
/// Assign-and-start: unified assign + spawn for both chat and MCP paths.
pub mod assign;
/// Canonical delete sequence for any work item type.
pub mod delete;
pub use assign::assign_and_start;