feat(story-93): expose server logs to agents via get_server_logs MCP tool

- Add log_buffer module: bounded 1000-line ring buffer with push/get_recent API
- Add slog! macro: drop-in for eprintln! that also captures to ring buffer
- Replace all eprintln! calls across agents, watcher, search, chat, worktree, claude_code with slog!
- Add get_server_logs MCP tool: accepts count (1-500) and optional filter params
- 5 unit tests for log_buffer covering push/retrieve, eviction, filtering, count limits, empty buffer
- 262 tests passing, clippy clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-23 20:38:19 +00:00
parent 3d480e7c22
commit 8c6bd4cf74
10 changed files with 243 additions and 66 deletions

View File

@@ -1,3 +1,4 @@
use crate::slog;
use crate::config::ProjectConfig;
use crate::worktree::{self, WorktreeInfo};
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
@@ -357,7 +358,7 @@ impl AgentPool {
// Preserve worktree for inspection — don't destroy agent's work on stop.
if let Some(ref wt) = worktree_info {
eprintln!(
slog!(
"[agents] Worktree preserved for {story_id}:{agent_name}: {}",
wt.path.display()
);
@@ -553,7 +554,7 @@ impl AgentPool {
let agents = match self.agents.lock() {
Ok(a) => a,
Err(e) => {
eprintln!("[pipeline] Failed to lock agents for '{story_id}:{agent_name}': {e}");
slog!("[pipeline] Failed to lock agents for '{story_id}:{agent_name}': {e}");
return;
}
};
@@ -571,14 +572,14 @@ impl AgentPool {
let completion = match completion {
Some(c) => c,
None => {
eprintln!("[pipeline] No completion report for '{story_id}:{agent_name}'");
slog!("[pipeline] No completion report for '{story_id}:{agent_name}'");
return;
}
};
let project_root = match project_root {
Some(p) => p,
None => {
eprintln!("[pipeline] No project_root for '{story_id}:{agent_name}'");
slog!("[pipeline] No project_root for '{story_id}:{agent_name}'");
return;
}
};
@@ -591,23 +592,23 @@ impl AgentPool {
}
PipelineStage::Coder => {
if completion.gates_passed {
eprintln!(
slog!(
"[pipeline] Coder '{agent_name}' passed gates for '{story_id}'. Moving to QA."
);
if let Err(e) = move_story_to_qa(&project_root, story_id) {
eprintln!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
slog!("[pipeline] Failed to move '{story_id}' to 3_qa/: {e}");
return;
}
if let Err(e) = self
.start_agent(&project_root, story_id, Some("qa"), None)
.await
{
eprintln!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
slog!("[pipeline] Failed to start qa agent for '{story_id}': {e}");
}
// Coder slot is now free — pick up any other unassigned work in 2_current/.
self.auto_assign_available_work(&project_root).await;
} else {
eprintln!(
slog!(
"[pipeline] Coder '{agent_name}' failed gates for '{story_id}'. Restarting."
);
let context = format!(
@@ -620,7 +621,7 @@ impl AgentPool {
.start_agent(&project_root, story_id, Some(agent_name), Some(&context))
.await
{
eprintln!(
slog!(
"[pipeline] Failed to restart coder '{agent_name}' for '{story_id}': {e}"
);
}
@@ -635,7 +636,7 @@ impl AgentPool {
tokio::task::spawn_blocking(move || run_coverage_gate(&cp))
.await
.unwrap_or_else(|e| {
eprintln!("[pipeline] Coverage gate task panicked: {e}");
slog!("[pipeline] Coverage gate task panicked: {e}");
Ok((false, format!("Coverage gate task panicked: {e}")))
});
let (coverage_passed, coverage_output) = match coverage_result {
@@ -644,23 +645,23 @@ impl AgentPool {
};
if coverage_passed {
eprintln!(
slog!(
"[pipeline] QA passed gates and coverage for '{story_id}'. Moving to merge."
);
if let Err(e) = move_story_to_merge(&project_root, story_id) {
eprintln!("[pipeline] Failed to move '{story_id}' to 4_merge/: {e}");
slog!("[pipeline] Failed to move '{story_id}' to 4_merge/: {e}");
return;
}
if let Err(e) = self
.start_agent(&project_root, story_id, Some("mergemaster"), None)
.await
{
eprintln!("[pipeline] Failed to start mergemaster for '{story_id}': {e}");
slog!("[pipeline] Failed to start mergemaster for '{story_id}': {e}");
}
// QA slot is now free — pick up any other unassigned work in 3_qa/.
self.auto_assign_available_work(&project_root).await;
} else {
eprintln!(
slog!(
"[pipeline] QA coverage gate failed for '{story_id}'. Restarting QA."
);
let context = format!(
@@ -673,11 +674,11 @@ impl AgentPool {
.start_agent(&project_root, story_id, Some("qa"), Some(&context))
.await
{
eprintln!("[pipeline] Failed to restart qa for '{story_id}': {e}");
slog!("[pipeline] Failed to restart qa for '{story_id}': {e}");
}
}
} else {
eprintln!(
slog!(
"[pipeline] QA failed gates for '{story_id}'. Restarting."
);
let context = format!(
@@ -690,20 +691,20 @@ impl AgentPool {
.start_agent(&project_root, story_id, Some("qa"), Some(&context))
.await
{
eprintln!("[pipeline] Failed to restart qa for '{story_id}': {e}");
slog!("[pipeline] Failed to restart qa for '{story_id}': {e}");
}
}
}
PipelineStage::Mergemaster => {
// Run script/test on master (project_root) as the post-merge verification.
eprintln!(
slog!(
"[pipeline] Mergemaster completed for '{story_id}'. Running post-merge tests on master."
);
let root = project_root.clone();
let test_result = tokio::task::spawn_blocking(move || run_project_tests(&root))
.await
.unwrap_or_else(|e| {
eprintln!("[pipeline] Post-merge test task panicked: {e}");
slog!("[pipeline] Post-merge test task panicked: {e}");
Ok((false, format!("Test task panicked: {e}")))
});
let (passed, output) = match test_result {
@@ -712,11 +713,11 @@ impl AgentPool {
};
if passed {
eprintln!(
slog!(
"[pipeline] Post-merge tests passed for '{story_id}'. Archiving."
);
if let Err(e) = move_story_to_archived(&project_root, story_id) {
eprintln!("[pipeline] Failed to archive '{story_id}': {e}");
slog!("[pipeline] Failed to archive '{story_id}': {e}");
}
// Mergemaster slot is now free — pick up any other items in 4_merge/.
self.auto_assign_available_work(&project_root).await;
@@ -728,15 +729,15 @@ impl AgentPool {
// worktree::remove_worktree_by_story_id(&project_root, story_id, &config)
// .await
// {
// eprintln!(
// slog!(
// "[pipeline] Failed to remove worktree for '{story_id}': {e}"
// );
// }
eprintln!(
slog!(
"[pipeline] Story '{story_id}' archived. Worktree preserved for inspection."
);
} else {
eprintln!(
slog!(
"[pipeline] Post-merge tests failed for '{story_id}'. Restarting mergemaster."
);
let context = format!(
@@ -749,7 +750,7 @@ impl AgentPool {
.start_agent(&project_root, story_id, Some("mergemaster"), Some(&context))
.await
{
eprintln!(
slog!(
"[pipeline] Failed to restart mergemaster for '{story_id}': {e}"
);
}
@@ -1037,7 +1038,7 @@ impl AgentPool {
let config = match ProjectConfig::load(project_root) {
Ok(c) => c,
Err(e) => {
eprintln!("[auto-assign] Failed to load project config: {e}");
slog!("[auto-assign] Failed to load project config: {e}");
return;
}
};
@@ -1062,7 +1063,7 @@ impl AgentPool {
let agents = match self.agents.lock() {
Ok(a) => a,
Err(e) => {
eprintln!("[auto-assign] Failed to lock agents: {e}");
slog!("[auto-assign] Failed to lock agents: {e}");
break;
}
};
@@ -1083,21 +1084,21 @@ impl AgentPool {
match free_agent {
Some(agent_name) => {
eprintln!(
slog!(
"[auto-assign] Assigning '{agent_name}' to '{story_id}' in {stage_dir}/"
);
if let Err(e) = self
.start_agent(project_root, story_id, Some(&agent_name), None)
.await
{
eprintln!(
slog!(
"[auto-assign] Failed to start '{agent_name}' for '{story_id}': {e}"
);
}
}
None => {
// No free agents of this type — stop scanning this stage.
eprintln!(
slog!(
"[auto-assign] All {:?} agents busy; remaining items in {stage_dir}/ will wait.",
stage
);
@@ -1228,7 +1229,7 @@ async fn run_server_owned_completion(
};
match lock.get(&key) {
Some(agent) if agent.completion.is_some() => {
eprintln!(
slog!(
"[agents] Completion already recorded for '{story_id}:{agent_name}'; \
skipping server-owned gates."
);
@@ -1269,7 +1270,7 @@ async fn run_server_owned_completion(
)
};
eprintln!(
slog!(
"[agents] Server-owned completion for '{story_id}:{agent_name}': gates_passed={gates_passed}"
);
@@ -1386,7 +1387,7 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(),
let source_path = source_dir.join(format!("{story_id}.md"));
if !source_path.exists() {
eprintln!(
slog!(
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to 2_current/",
source_dir.display()
);
@@ -1399,7 +1400,7 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(),
std::fs::rename(&source_path, &current_path)
.map_err(|e| format!("Failed to move '{story_id}' to 2_current/: {e}"))?;
eprintln!(
slog!(
"[lifecycle] Moved '{story_id}' from {} to work/2_current/",
source_dir.display()
);
@@ -1446,7 +1447,7 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
} else {
"work/4_merge/"
};
eprintln!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/");
slog!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/");
Ok(())
}
@@ -1488,7 +1489,7 @@ pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), St
} else {
"work/3_qa/"
};
eprintln!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
slog!("[lifecycle] Moved '{story_id}' from {from_dir} to work/4_merge/");
Ok(())
}
@@ -1519,7 +1520,7 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
std::fs::rename(&current_path, &qa_path)
.map_err(|e| format!("Failed to move '{story_id}' to 3_qa/: {e}"))?;
eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
slog!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/3_qa/");
Ok(())
}
@@ -1556,7 +1557,7 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
std::fs::rename(&source_path, &archive_path)
.map_err(|e| format!("Failed to move bug '{bug_id}' to 5_archived/: {e}"))?;
eprintln!(
slog!(
"[lifecycle] Closed bug '{bug_id}' → work/5_archived/"
);
@@ -1956,7 +1957,7 @@ fn run_agent_pty_blocking(
cmd.env_remove("CLAUDECODE");
cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
eprintln!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
slog!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}");
let mut child = pair
.slave
@@ -2050,7 +2051,7 @@ fn run_agent_pty_blocking(
let _ = child.kill();
let _ = child.wait();
eprintln!(
slog!(
"[agent:{story_id}:{agent_name}] Done. Session: {:?}",
session_id
);