story-kit: accept 96_story_reset_agent_lozenge_to_idle_state_when_returning_to_roster

This commit is contained in:
Dave
2026-02-23 20:52:06 +00:00
parent 7f18542c09
commit bed46fea1b
13 changed files with 627 additions and 48 deletions

View File

@@ -1,3 +1,4 @@
use crate::agent_log::AgentLogWriter;
use crate::config::ProjectConfig;
use crate::worktree::{self, WorktreeInfo};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
@@ -113,6 +114,8 @@ pub struct AgentInfo {
pub worktree_path: Option<String>,
pub base_branch: Option<String>,
pub completion: Option<CompletionReport>,
/// UUID identifying the persistent log file for this session.
pub log_session_id: Option<String>,
}
struct StoryAgent {
@@ -128,6 +131,8 @@ struct StoryAgent {
completion: Option<CompletionReport>,
/// Project root, stored for pipeline advancement after completion.
project_root: Option<PathBuf>,
/// UUID identifying the log file for this session.
log_session_id: Option<String>,
}
/// Build an `AgentInfo` snapshot from a `StoryAgent` map entry.
@@ -146,6 +151,7 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo {
.as_ref()
.map(|wt| wt.base_branch.clone()),
completion: agent.completion.clone(),
log_session_id: agent.log_session_id.clone(),
}
}
@@ -210,6 +216,23 @@ impl AgentPool {
let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
// Generate a unique session ID for the persistent log file.
let log_session_id = uuid::Uuid::new_v4().to_string();
// Create persistent log writer.
let log_writer = match AgentLogWriter::new(
project_root,
story_id,
&resolved_name,
&log_session_id,
) {
Ok(w) => Some(Arc::new(Mutex::new(w))),
Err(e) => {
eprintln!("[agents] Failed to create log writer for {story_id}:{resolved_name}: {e}");
None
}
};
// Register as pending
{
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
@@ -225,6 +248,7 @@ impl AgentPool {
event_log: event_log.clone(),
completion: None,
project_root: Some(project_root.to_path_buf()),
log_session_id: Some(log_session_id.clone()),
},
);
}
@@ -267,6 +291,7 @@ impl AgentPool {
let key_clone = key.clone();
let log_clone = event_log.clone();
let port_for_task = self.port;
let log_writer_clone = log_writer.clone();
let handle = tokio::spawn(async move {
let _ = tx_clone.send(AgentEvent::Status {
@@ -277,6 +302,7 @@ impl AgentPool {
match run_agent_pty_streaming(
&sid, &aname, &command, &args, &prompt, &cwd, &tx_clone, &log_clone,
log_writer_clone,
)
.await
{
@@ -324,6 +350,7 @@ impl AgentPool {
worktree_path: Some(wt_path_str),
base_branch: Some(wt_info.base_branch.clone()),
completion: None,
log_session_id: Some(log_session_id),
})
}
@@ -487,6 +514,7 @@ impl AgentPool {
worktree_path: None,
base_branch: None,
completion: None,
log_session_id: None,
}
});
}
@@ -962,6 +990,22 @@ impl AgentPool {
state.get_project_root()
}
/// Get the log session ID and project root for an agent, if available.
///
/// Used by MCP tools to find the persistent log file for a completed agent.
pub fn get_log_info(
&self,
story_id: &str,
agent_name: &str,
) -> Option<(String, PathBuf)> {
let key = composite_key(story_id, agent_name);
let agents = self.agents.lock().ok()?;
let agent = agents.get(&key)?;
let session_id = agent.log_session_id.clone()?;
let project_root = agent.project_root.clone()?;
Some((session_id, project_root))
}
/// Test helper: inject a pre-built agent entry so unit tests can exercise
/// wait/subscribe logic without spawning a real process.
#[cfg(test)]
@@ -986,6 +1030,7 @@ impl AgentPool {
event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
project_root: None,
log_session_id: None,
},
);
tx
@@ -1020,6 +1065,7 @@ impl AgentPool {
event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
project_root: None,
log_session_id: None,
},
);
tx
@@ -1279,6 +1325,7 @@ impl AgentPool {
event_log: Arc::new(Mutex::new(Vec::new())),
completion: Some(completion),
project_root: Some(project_root),
log_session_id: None,
},
);
tx
@@ -2078,6 +2125,7 @@ async fn run_agent_pty_streaming(
cwd: &str,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
) -> Result<Option<String>, String> {
let sid = story_id.to_string();
let aname = agent_name.to_string();
@@ -2089,21 +2137,38 @@ async fn run_agent_pty_streaming(
let event_log = event_log.clone();
tokio::task::spawn_blocking(move || {
run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx, &event_log)
run_agent_pty_blocking(
&sid,
&aname,
&cmd,
&args,
&prompt,
&cwd,
&tx,
&event_log,
log_writer.as_deref(),
)
})
.await
.map_err(|e| format!("Agent task panicked: {e}"))?
}
/// Helper to send an event to both broadcast and event log.
/// Helper to send an event to broadcast, event log, and optional persistent log file.
fn emit_event(
event: AgentEvent,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Mutex<Vec<AgentEvent>>,
log_writer: Option<&Mutex<AgentLogWriter>>,
) {
if let Ok(mut log) = event_log.lock() {
log.push(event.clone());
}
if let Some(writer) = log_writer
&& let Ok(mut w) = writer.lock()
&& let Err(e) = w.write_event(&event)
{
eprintln!("[agent_log] Failed to write event to log file: {e}");
}
let _ = tx.send(event);
}
@@ -2117,6 +2182,7 @@ fn run_agent_pty_blocking(
cwd: &str,
tx: &broadcast::Sender<AgentEvent>,
event_log: &Mutex<Vec<AgentEvent>>,
log_writer: Option<&Mutex<AgentLogWriter>>,
) -> Result<Option<String>, String> {
let pty_system = native_pty_system();
@@ -2198,6 +2264,7 @@ fn run_agent_pty_blocking(
},
tx,
event_log,
log_writer,
);
continue;
}
@@ -2226,6 +2293,7 @@ fn run_agent_pty_blocking(
},
tx,
event_log,
log_writer,
);
}
}
@@ -2243,6 +2311,7 @@ fn run_agent_pty_blocking(
},
tx,
event_log,
log_writer,
);
}
@@ -3514,4 +3583,38 @@ name = "qa"
"story should be in 2_current/ or 3_qa/ after reconciliation"
);
}
#[test]
fn test_emit_event_writes_to_log_writer() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let log_writer =
AgentLogWriter::new(root, "42_story_foo", "coder-1", "sess-emit").unwrap();
let log_mutex = Mutex::new(log_writer);
let (tx, _rx) = broadcast::channel::<AgentEvent>(64);
let event_log: Mutex<Vec<AgentEvent>> = Mutex::new(Vec::new());
let event = AgentEvent::Status {
story_id: "42_story_foo".to_string(),
agent_name: "coder-1".to_string(),
status: "running".to_string(),
};
emit_event(event, &tx, &event_log, Some(&log_mutex));
// Verify event was added to in-memory log
let mem_events = event_log.lock().unwrap();
assert_eq!(mem_events.len(), 1);
drop(mem_events);
// Verify event was written to the log file
let log_path =
crate::agent_log::log_file_path(root, "42_story_foo", "coder-1", "sess-emit");
let entries = crate::agent_log::read_log(&log_path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].event["type"], "status");
assert_eq!(entries[0].event["status"], "running");
}
}