2026-03-24 15:03:17 +00:00
|
|
|
//! Assign command: pre-assign or re-assign a coder model to a story.
|
|
|
|
|
//!
|
2026-04-29 22:04:47 +00:00
|
|
|
//! `{bot_name} assign {number} {model}` finds the story by number, writes the
|
|
|
|
|
//! agent name into the typed CRDT `agent` register, and — when a coder is
|
|
|
|
|
//! already running on the story — stops it and starts the newly-assigned one
|
|
|
|
|
//! via [`crate::service::work_item::assign_and_start`].
|
2026-03-24 15:03:17 +00:00
|
|
|
//!
|
|
|
|
|
//! When no coder is running (the story has not been started yet), the command
|
2026-04-29 22:04:47 +00:00
|
|
|
//! persists the assignment in the CRDT register so the next `start` invocation
|
|
|
|
|
//! picks it up automatically.
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
use crate::agents::{AgentPool, AgentStatus};
|
2026-03-28 18:33:22 +00:00
|
|
|
use crate::chat::util::strip_bot_mention;
|
2026-05-08 14:24:20 +00:00
|
|
|
use crate::db::yaml_legacy::parse_front_matter;
|
2026-03-24 15:03:17 +00:00
|
|
|
use std::path::Path;
|
|
|
|
|
|
|
|
|
|
/// A parsed assign command from a Matrix message body.
|
|
|
|
|
#[derive(Debug, PartialEq)]
|
|
|
|
|
pub enum AssignCommand {
|
|
|
|
|
/// Assign the story with this number to the given model.
|
2026-04-13 14:07:08 +00:00
|
|
|
Assign { story_number: String, model: String },
|
2026-03-24 15:03:17 +00:00
|
|
|
/// The user typed `assign` but without valid arguments.
|
|
|
|
|
BadArgs,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parse an assign command from a raw Matrix message body.
|
|
|
|
|
///
|
|
|
|
|
/// Strips the bot mention prefix and checks whether the first word is `assign`.
|
|
|
|
|
/// Returns `None` when the message is not an assign command at all.
|
|
|
|
|
pub fn extract_assign_command(
|
|
|
|
|
message: &str,
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
bot_user_id: &str,
|
|
|
|
|
) -> Option<AssignCommand> {
|
2026-03-28 18:33:22 +00:00
|
|
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
2026-03-24 15:03:17 +00:00
|
|
|
let trimmed = stripped
|
|
|
|
|
.trim()
|
|
|
|
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
|
|
|
|
|
|
|
|
|
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
|
|
|
|
Some((c, a)) => (c, a.trim()),
|
|
|
|
|
None => (trimmed, ""),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if !cmd.eq_ignore_ascii_case("assign") {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Split args into story number and model.
|
|
|
|
|
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
|
|
|
|
|
Some((n, m)) => (n.trim(), m.trim()),
|
|
|
|
|
None => (args, ""),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if number_str.is_empty()
|
|
|
|
|
|| !number_str.chars().all(|c| c.is_ascii_digit())
|
|
|
|
|
|| model_str.is_empty()
|
|
|
|
|
{
|
|
|
|
|
return Some(AssignCommand::BadArgs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(AssignCommand::Assign {
|
|
|
|
|
story_number: number_str.to_string(),
|
|
|
|
|
model: model_str.to_string(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
|
|
|
|
|
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
|
|
|
|
|
/// it is returned unchanged to prevent double-prefixing.
|
|
|
|
|
pub fn resolve_agent_name(model: &str) -> String {
|
|
|
|
|
if model.starts_with("coder-") {
|
|
|
|
|
model.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
format!("coder-{model}")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Handle an assign command asynchronously.
|
|
|
|
|
///
|
2026-04-29 22:04:47 +00:00
|
|
|
/// Finds the work item by `story_number` across all pipeline stages, writes
|
|
|
|
|
/// the agent pin to the CRDT register, and — if a coder is currently running
|
|
|
|
|
/// on the story — stops it and starts the newly-assigned agent via
|
|
|
|
|
/// [`crate::service::work_item::assign_and_start`].
|
|
|
|
|
///
|
|
|
|
|
/// When no coder is running the assignment is persisted in the CRDT so the
|
|
|
|
|
/// next `start` invocation picks it up automatically. Returns a
|
|
|
|
|
/// markdown-formatted response string.
|
2026-03-24 15:03:17 +00:00
|
|
|
pub async fn handle_assign(
|
|
|
|
|
bot_name: &str,
|
|
|
|
|
story_number: &str,
|
|
|
|
|
model_str: &str,
|
|
|
|
|
project_root: &Path,
|
|
|
|
|
agents: &AgentPool,
|
|
|
|
|
) -> String {
|
2026-04-29 22:04:47 +00:00
|
|
|
// Parse: find the story by numeric prefix (CRDT → content store → filesystem).
|
2026-04-10 14:56:13 +00:00
|
|
|
let (story_id, _stage_dir, _path, content) =
|
2026-04-09 23:00:01 +00:00
|
|
|
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
|
|
|
|
Some(found) => found,
|
|
|
|
|
None => {
|
2026-04-13 14:07:08 +00:00
|
|
|
return format!("No story, bug, or spike with number **{story_number}** found.");
|
2026-04-08 03:03:59 +00:00
|
|
|
}
|
2026-04-09 23:00:01 +00:00
|
|
|
};
|
2026-03-24 15:03:17 +00:00
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
let story_name = content
|
|
|
|
|
.or_else(|| crate::db::read_content(&story_id))
|
|
|
|
|
.and_then(|c| parse_front_matter(&c).ok().and_then(|m| m.name))
|
2026-03-24 15:03:17 +00:00
|
|
|
.unwrap_or_else(|| story_id.clone());
|
|
|
|
|
|
|
|
|
|
let agent_name = resolve_agent_name(model_str);
|
|
|
|
|
|
|
|
|
|
// Check whether a coder is already running on this story.
|
|
|
|
|
let running_coders: Vec<_> = agents
|
|
|
|
|
.list_agents()
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter(|a| {
|
|
|
|
|
a.story_id == story_id
|
|
|
|
|
&& a.agent_name.starts_with("coder")
|
|
|
|
|
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if running_coders.is_empty() {
|
2026-04-29 22:04:47 +00:00
|
|
|
// No coder running — persist the CRDT agent pin for the future start.
|
|
|
|
|
crate::crdt_state::set_agent(&story_id, Some(&agent_name));
|
2026-03-24 15:03:17 +00:00
|
|
|
return format!(
|
|
|
|
|
"Assigned **{agent_name}** to **{story_name}** (story {story_number}). \
|
|
|
|
|
The model will be used when the story starts."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
// Stop each running coder, then assign+start the newly-assigned one.
|
2026-03-24 15:03:17 +00:00
|
|
|
let stopped: Vec<String> = running_coders
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|a| a.agent_name.clone())
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
for coder in &running_coders {
|
|
|
|
|
if let Err(e) = agents
|
|
|
|
|
.stop_agent(project_root, &story_id, &coder.agent_name)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[matrix-bot] assign: failed to stop agent {} for {}: {e}",
|
|
|
|
|
coder.agent_name,
|
|
|
|
|
story_id
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[matrix-bot] assign (bot={bot_name}): stopped {:?} for {}; starting {agent_name}",
|
|
|
|
|
stopped,
|
|
|
|
|
story_id
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
// Service call: persist CRDT agent pin and start the new agent.
|
|
|
|
|
match crate::service::work_item::assign_and_start(&story_id, &agent_name, project_root, agents)
|
2026-03-24 15:03:17 +00:00
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(info) => {
|
|
|
|
|
format!(
|
|
|
|
|
"Reassigned **{story_name}** (story {story_number}): \
|
|
|
|
|
stopped **{}** and started **{}**.",
|
|
|
|
|
stopped.join(", "),
|
|
|
|
|
info.agent_name
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
format!(
|
|
|
|
|
"Assigned **{agent_name}** to **{story_name}** (story {story_number}): \
|
|
|
|
|
stopped **{}** but failed to start the new agent: {e}",
|
|
|
|
|
stopped.join(", ")
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
// -- extract_assign_command -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_with_full_user_id() {
|
|
|
|
|
let cmd = extract_assign_command(
|
|
|
|
|
"@timmy:home.local assign 42 opus",
|
|
|
|
|
"Timmy",
|
|
|
|
|
"@timmy:home.local",
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cmd,
|
|
|
|
|
Some(AssignCommand::Assign {
|
|
|
|
|
story_number: "42".to_string(),
|
|
|
|
|
model: "opus".to_string()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_with_display_name() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy assign 42 sonnet", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cmd,
|
|
|
|
|
Some(AssignCommand::Assign {
|
|
|
|
|
story_number: "42".to_string(),
|
|
|
|
|
model: "sonnet".to_string()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_with_localpart() {
|
|
|
|
|
let cmd = extract_assign_command("@timmy assign 7 opus", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cmd,
|
|
|
|
|
Some(AssignCommand::Assign {
|
|
|
|
|
story_number: "7".to_string(),
|
|
|
|
|
model: "opus".to_string()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_case_insensitive_command() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy ASSIGN 99 opus", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
cmd,
|
|
|
|
|
Some(AssignCommand::Assign {
|
|
|
|
|
story_number: "99".to_string(),
|
|
|
|
|
model: "opus".to_string()
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_no_args_is_bad_args() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy assign", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_missing_model_is_bad_args() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy assign 42", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_non_numeric_number_is_bad_args() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy assign abc opus", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn extract_non_assign_command_returns_none() {
|
|
|
|
|
let cmd = extract_assign_command("Timmy help", "Timmy", "@timmy:home.local");
|
|
|
|
|
assert_eq!(cmd, None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 16:30:44 +00:00
|
|
|
#[test]
|
|
|
|
|
fn extract_assign_command_multibyte_prefix_no_panic() {
|
|
|
|
|
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
|
|
|
|
|
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
|
2026-04-13 14:07:08 +00:00
|
|
|
let cmd =
|
|
|
|
|
extract_assign_command("xxxx\u{23FA} assign 42 opus", "Timmy", "@timmy:home.local");
|
2026-03-28 16:30:44 +00:00
|
|
|
assert_eq!(cmd, None);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-24 15:03:17 +00:00
|
|
|
// -- resolve_agent_name --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_agent_name_prefixes_bare_model() {
|
|
|
|
|
assert_eq!(resolve_agent_name("opus"), "coder-opus");
|
|
|
|
|
assert_eq!(resolve_agent_name("sonnet"), "coder-sonnet");
|
|
|
|
|
assert_eq!(resolve_agent_name("haiku"), "coder-haiku");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_agent_name_does_not_double_prefix() {
|
|
|
|
|
assert_eq!(resolve_agent_name("coder-opus"), "coder-opus");
|
|
|
|
|
assert_eq!(resolve_agent_name("coder-sonnet"), "coder-sonnet");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- handle_assign (no running coder) ------------------------------------
|
|
|
|
|
|
2026-03-28 19:47:59 +00:00
|
|
|
use crate::chat::test_helpers::write_story_file;
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_assign_returns_not_found_for_unknown_number() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
|
|
|
|
let response = handle_assign("Timmy", "999", "opus", tmp.path(), &agents).await;
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("No story") && response.contains("999"),
|
|
|
|
|
"unexpected response: {response}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
2026-04-29 22:04:47 +00:00
|
|
|
async fn handle_assign_sets_crdt_agent_when_no_coder_running() {
|
|
|
|
|
crate::crdt_state::init_for_test();
|
2026-03-24 15:03:17 +00:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"1_backlog",
|
2026-04-10 14:56:13 +00:00
|
|
|
"9972_story_test.md",
|
|
|
|
|
"---\nname: Test Feature\n---\n\n# Story 9972\n",
|
2026-03-24 15:03:17 +00:00
|
|
|
);
|
2026-04-29 22:04:47 +00:00
|
|
|
// Seed CRDT so set_agent can write to the item.
|
|
|
|
|
crate::crdt_state::write_item(
|
|
|
|
|
"9972_story_test",
|
|
|
|
|
"1_backlog",
|
|
|
|
|
Some("Test Feature"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
2026-04-10 14:56:13 +00:00
|
|
|
let response = handle_assign("Timmy", "9972", "opus", tmp.path(), &agents).await;
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("coder-opus"),
|
|
|
|
|
"response should mention agent: {response}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("Test Feature"),
|
|
|
|
|
"response should mention story name: {response}"
|
|
|
|
|
);
|
|
|
|
|
// Should say "will be used when the story starts" (no restart)
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("start"),
|
|
|
|
|
"response should indicate assignment for future start: {response}"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
// CRDT register should be set (no longer checks YAML front matter).
|
|
|
|
|
let dump = crate::crdt_state::dump_crdt_state(Some("9972_story_test"));
|
|
|
|
|
let item = dump
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.as_deref() == Some("9972_story_test"))
|
|
|
|
|
.expect("item must be in CRDT");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
item.agent.as_deref(),
|
|
|
|
|
Some("coder-opus"),
|
|
|
|
|
"CRDT agent register should be set: {:?}",
|
|
|
|
|
item.agent
|
2026-03-24 15:03:17 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_assign_with_already_prefixed_name_does_not_double_prefix() {
|
2026-04-29 22:04:47 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-03-24 15:03:17 +00:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"1_backlog",
|
2026-04-10 14:56:13 +00:00
|
|
|
"9973_story_small.md",
|
2026-03-24 15:03:17 +00:00
|
|
|
"---\nname: Small Story\n---\n",
|
|
|
|
|
);
|
2026-04-29 22:04:47 +00:00
|
|
|
crate::crdt_state::write_item(
|
|
|
|
|
"9973_story_small",
|
|
|
|
|
"1_backlog",
|
|
|
|
|
Some("Small Story"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
2026-04-10 14:56:13 +00:00
|
|
|
let response = handle_assign("Timmy", "9973", "coder-opus", tmp.path(), &agents).await;
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("coder-opus"),
|
|
|
|
|
"should not double-prefix: {response}"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
!response.contains("coder-coder-opus"),
|
|
|
|
|
"must not double-prefix: {response}"
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
// CRDT must have coder-opus, not coder-coder-opus.
|
|
|
|
|
let dump = crate::crdt_state::dump_crdt_state(Some("9973_story_small"));
|
|
|
|
|
let item = dump
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.as_deref() == Some("9973_story_small"))
|
|
|
|
|
.expect("item must be in CRDT");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
item.agent.as_deref(),
|
|
|
|
|
Some("coder-opus"),
|
|
|
|
|
"must write coder-opus, not coder-coder-opus: {:?}",
|
|
|
|
|
item.agent
|
2026-03-24 15:03:17 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_assign_overwrites_existing_agent_field() {
|
2026-04-29 22:04:47 +00:00
|
|
|
crate::crdt_state::init_for_test();
|
2026-03-24 15:03:17 +00:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"1_backlog",
|
2026-04-10 14:56:13 +00:00
|
|
|
"9974_story_existing.md",
|
2026-03-24 15:03:17 +00:00
|
|
|
"---\nname: Existing\nagent: coder-sonnet\n---\n",
|
|
|
|
|
);
|
2026-04-29 22:04:47 +00:00
|
|
|
crate::crdt_state::write_item(
|
|
|
|
|
"9974_story_existing",
|
|
|
|
|
"1_backlog",
|
|
|
|
|
Some("Existing"),
|
|
|
|
|
Some("coder-sonnet"),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
);
|
2026-03-24 15:03:17 +00:00
|
|
|
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
2026-04-10 14:56:13 +00:00
|
|
|
handle_assign("Timmy", "9974", "opus", tmp.path(), &agents).await;
|
2026-03-24 15:03:17 +00:00
|
|
|
|
2026-04-29 22:04:47 +00:00
|
|
|
// CRDT agent register must be updated to the new value.
|
|
|
|
|
let dump = crate::crdt_state::dump_crdt_state(Some("9974_story_existing"));
|
|
|
|
|
let item = dump
|
|
|
|
|
.items
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|i| i.story_id.as_deref() == Some("9974_story_existing"))
|
|
|
|
|
.expect("item must be in CRDT");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
item.agent.as_deref(),
|
|
|
|
|
Some("coder-opus"),
|
|
|
|
|
"CRDT agent must be updated to coder-opus: {:?}",
|
|
|
|
|
item.agent
|
2026-03-24 15:03:17 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_assign_finds_story_in_any_stage() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"3_qa",
|
|
|
|
|
"99_story_in_qa.md",
|
|
|
|
|
"---\nname: In QA\n---\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
|
|
|
|
let response = handle_assign("Timmy", "99", "opus", tmp.path(), &agents).await;
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("coder-opus"),
|
|
|
|
|
"should find story in qa stage: {response}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// -- handle_assign (with running coder) ----------------------------------
|
|
|
|
|
|
|
|
|
|
#[tokio::test]
|
|
|
|
|
async fn handle_assign_stops_running_coder_and_reports_reassignment() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
write_story_file(
|
|
|
|
|
tmp.path(),
|
|
|
|
|
"2_current",
|
|
|
|
|
"10_story_current.md",
|
|
|
|
|
"---\nname: Current Story\nagent: coder-sonnet\n---\n",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
|
|
|
|
// Inject a running coder for this story.
|
|
|
|
|
agents.inject_test_agent("10_story_current", "coder-sonnet", AgentStatus::Running);
|
|
|
|
|
|
|
|
|
|
let response = handle_assign("Timmy", "10", "opus", tmp.path(), &agents).await;
|
|
|
|
|
|
|
|
|
|
// The response should mention both stopped and started agents.
|
|
|
|
|
assert!(
|
|
|
|
|
response.contains("coder-sonnet"),
|
|
|
|
|
"response should mention the stopped agent: {response}"
|
|
|
|
|
);
|
|
|
|
|
// Should indicate a restart occurred (not just "will be used when starts")
|
|
|
|
|
assert!(
|
2026-04-13 14:07:08 +00:00
|
|
|
response.to_lowercase().contains("stop")
|
|
|
|
|
|| response.to_lowercase().contains("reassign"),
|
2026-03-24 15:03:17 +00:00
|
|
|
"response should indicate stop/reassign: {response}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|