From 51fad34a6a70a33be6d76a2b0fe80cd02ed8bd00 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 24 Feb 2026 23:57:07 +0000 Subject: [PATCH] story-kit: merge 173_bug_pipeline_board_lozenges_dont_update_on_agent_state_changes --- server/src/agents.rs | 97 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 91 insertions(+), 6 deletions(-) diff --git a/server/src/agents.rs b/server/src/agents.rs index b67054f..79c3fa5 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -587,6 +587,7 @@ impl AgentPool { &sid, &aname, session_id, + watcher_tx_clone.clone(), ) .await; Self::notify_agent_state_changed(&watcher_tx_clone); @@ -2002,6 +2003,7 @@ async fn run_server_owned_completion( story_id: &str, agent_name: &str, session_id: Option, + watcher_tx: broadcast::Sender, ) { let key = composite_key(story_id, agent_name); @@ -2094,7 +2096,7 @@ async fn run_server_owned_completion( // Advance the pipeline state machine in a background task. // Uses a non-async helper to break the opaque type cycle. - spawn_pipeline_advance(Arc::clone(agents), port, story_id, agent_name); + spawn_pipeline_advance(Arc::clone(agents), port, story_id, agent_name, watcher_tx); } /// Spawn pipeline advancement as a background task. @@ -2106,11 +2108,11 @@ fn spawn_pipeline_advance( port: u16, story_id: &str, agent_name: &str, + watcher_tx: broadcast::Sender, ) { let sid = story_id.to_string(); let aname = agent_name.to_string(); tokio::spawn(async move { - let (watcher_tx, _) = broadcast::channel(16); let pool = AgentPool { agents, port, @@ -3667,7 +3669,7 @@ mod tests { // Subscribe before calling so we can check if Done event was emitted. let mut rx = pool.subscribe("s10", "coder-1").unwrap(); - run_server_owned_completion(&pool.agents, pool.port, "s10", "coder-1", Some("sess-1".to_string())) + run_server_owned_completion(&pool.agents, pool.port, "s10", "coder-1", Some("sess-1".to_string()), pool.watcher_tx.clone()) .await; // Status should remain Completed (unchanged) — no gate re-run. @@ -3707,7 +3709,7 @@ mod tests { let mut rx = pool.subscribe("s11", "coder-1").unwrap(); - run_server_owned_completion(&pool.agents, pool.port, "s11", "coder-1", Some("sess-2".to_string())) + run_server_owned_completion(&pool.agents, pool.port, "s11", "coder-1", Some("sess-2".to_string()), pool.watcher_tx.clone()) .await; // Completion report should exist (gates were run, though they may fail @@ -3760,7 +3762,7 @@ mod tests { repo.to_path_buf(), ); - run_server_owned_completion(&pool.agents, pool.port, "s12", "coder-1", None) + run_server_owned_completion(&pool.agents, pool.port, "s12", "coder-1", None, pool.watcher_tx.clone()) .await; let agents = pool.agents.lock().unwrap(); @@ -3784,7 +3786,7 @@ mod tests { async fn server_owned_completion_nonexistent_agent_is_noop() { let pool = AgentPool::new_test(3001); // Should not panic or error — just silently return. - run_server_owned_completion(&pool.agents, pool.port, "nonexistent", "bot", None) + run_server_owned_completion(&pool.agents, pool.port, "nonexistent", "bot", None, pool.watcher_tx.clone()) .await; } @@ -6383,4 +6385,87 @@ theirs "child_killers should be cleared after kill_all_children" ); } + + // ── Bug 173: pipeline advance sends AgentStateChanged via real watcher_tx ─ + + #[tokio::test] + async fn pipeline_advance_sends_agent_state_changed_to_watcher_tx() { + use std::fs; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + + // Set up story in 2_current/ + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("173_story_test.md"), "test").unwrap(); + // Ensure 3_qa/ exists for the move target + fs::create_dir_all(root.join(".story_kit/work/3_qa")).unwrap(); + // Ensure 1_upcoming/ exists (start_agent calls move_story_to_current) + fs::create_dir_all(root.join(".story_kit/work/1_upcoming")).unwrap(); + + // Write a project.toml with a qa agent so start_agent can resolve it. + fs::create_dir_all(root.join(".story_kit")).unwrap(); + fs::write( + root.join(".story_kit/project.toml"), + r#" +[[agent]] +name = "coder-1" +role = "Coder" +command = "echo" +args = ["noop"] +prompt = "test" +stage = "coder" + +[[agent]] +name = "qa" +role = "QA" +command = "echo" +args = ["noop"] +prompt = "test" +stage = "qa" +"#, + ) + .unwrap(); + + let pool = AgentPool::new_test(3001); + pool.inject_test_agent_with_completion( + "173_story_test", + "coder-1", + AgentStatus::Completed, + root.to_path_buf(), + CompletionReport { + summary: "done".to_string(), + gates_passed: true, + gate_output: String::new(), + }, + ); + + // Subscribe to the watcher channel BEFORE the pipeline advance. + let mut rx = pool.watcher_tx.subscribe(); + + // Call pipeline advance directly. This will: + // 1. Move the story to 3_qa/ + // 2. Start the QA agent (which calls notify_agent_state_changed) + // Note: the actual agent process will fail (no real worktree), but the + // agent insertion and notification happen before the background spawn. + pool.run_pipeline_advance_for_completed_agent("173_story_test", "coder-1") + .await; + + // The pipeline advance should have sent AgentStateChanged events via + // the pool's watcher_tx (not a dummy channel). Collect all events. + let mut got_agent_state_changed = false; + while let Ok(evt) = rx.try_recv() { + if matches!(evt, WatcherEvent::AgentStateChanged) { + got_agent_state_changed = true; + break; + } + } + + assert!( + got_agent_state_changed, + "pipeline advance should send AgentStateChanged through the real watcher_tx \ + (bug 173: lozenges must update when agents are assigned during pipeline advance)" + ); + } }