fix: merge_agent_work blocks until complete instead of requiring polling

The mergemaster agent was burning all 30 turns polling get_merge_status
every 2 seconds while the merge pipeline takes ~2 minutes. It would
exhaust turns, exit, restart, and repeat — never seeing the result.

merge_agent_work now blocks with a 10-second internal poll loop and
returns the final result directly. The agent calls it once and gets
the answer. No more polling turns wasted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-11 17:43:50 +00:00
parent 599fbdc71d
commit d06241c20c
9 changed files with 242 additions and 159 deletions
+26 -34
View File
@@ -7,7 +7,7 @@
//! Passing no dependency numbers clears the field entirely.
use super::CommandContext;
use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
use crate::io::story_metadata::{parse_front_matter, write_depends_on};
/// Handle the `depends` command.
///
@@ -51,7 +51,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
}
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, stage_dir, _path, content) =
let (story_id, _stage_dir, path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found,
None => {
@@ -61,36 +61,24 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
}
};
let contents = match content.or_else(|| crate::db::read_content(&story_id)) {
Some(c) => c,
None => return Some(format!("No content found for **{story_id}**.")),
};
let story_name = parse_front_matter(&contents)
.ok()
let story_name = content
.or_else(|| std::fs::read_to_string(&path).ok())
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.name)
.unwrap_or_else(|| story_id.clone());
// Update depends_on in the content store + CRDT.
let updated = if deps.is_empty() {
clear_front_matter_field_in_content(&contents, "depends_on")
} else {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
let yaml_value = format!("[{}]", nums.join(", "));
set_front_matter_field(&contents, "depends_on", &yaml_value)
};
crate::db::write_item_with_content(&story_id, &stage_dir, &updated);
if deps.is_empty() {
Some(format!(
match write_depends_on(&path, &deps) {
Ok(()) if deps.is_empty() => Some(format!(
"Cleared all dependencies for **{story_name}** ({story_id})."
))
} else {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
Some(format!(
"Set depends_on: [{}] for **{story_name}** ({story_id}).",
nums.join(", ")
))
)),
Ok(()) => {
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
Some(format!(
"Set depends_on: [{}] for **{story_name}** ({story_id}).",
nums.join(", ")
))
}
Err(e) => Some(format!("Failed to update dependencies for {story_id}: {e}")),
}
}
@@ -200,11 +188,13 @@ mod tests {
output.contains("477") && output.contains("478"),
"response should mention dep numbers: {output}"
);
let contents = crate::db::read_content("42_story_foo")
.expect("content store should have the story");
let contents = std::fs::read_to_string(
tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"),
)
.unwrap();
assert!(
contents.contains("depends_on: [477, 478]"),
"content store should have depends_on set: {contents}"
"file should have depends_on set: {contents}"
);
}
@@ -222,11 +212,13 @@ mod tests {
output.contains("Cleared"),
"should confirm clearing deps: {output}"
);
let contents = crate::db::read_content("10_story_bar")
.expect("content store should have the story");
let contents = std::fs::read_to_string(
tmp.path().join(".huskies/work/2_current/10_story_bar.md"),
)
.unwrap();
assert!(
!contents.contains("depends_on"),
"content store should have depends_on cleared: {contents}"
"file should have depends_on cleared: {contents}"
);
}
+63 -4
View File
@@ -5,7 +5,7 @@
//! and returns a confirmation.
use super::CommandContext;
use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
use std::path::Path;
/// Handle the `unblock` command.
@@ -33,7 +33,7 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
///
/// Lookup priority: CRDT → content store → filesystem (Story 512).
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
let (story_id, _stage_dir, _path, _content) =
let (story_id, _stage_dir, path, _content) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
@@ -43,8 +43,15 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
}
};
// All state lives in the content store + CRDT now.
unblock_by_story_id(&story_id)
// Prefer DB-backed unblock when the story is in the content store.
// Note: `content` may have come from the filesystem fallback in
// `find_story_by_number`, so we must re-check the DB rather than
// relying on `content.is_some()` alone.
if crate::db::read_content(&story_id).is_some() {
unblock_by_story_id(&story_id)
} else {
unblock_by_path(&path, &story_id)
}
}
/// Unblock a story using the content store (DB-backed).
@@ -92,6 +99,58 @@ fn unblock_by_story_id(story_id: &str) -> String {
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
}
/// Core unblock logic: reset blocked state for a known story file path.
///
/// Reads front matter, verifies the story is blocked, clears the `blocked`
/// flag, and resets `retry_count` to 0. Also used by the MCP `unblock` tool
/// when the caller has already resolved the story path from a full `story_id`.
pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return format!("Failed to read story file: {e}"),
};
let meta = match parse_front_matter(&contents) {
Ok(m) => m,
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
};
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
let has_blocked = meta.blocked == Some(true);
let has_merge_failure = meta.merge_failure.is_some();
if !has_blocked && !has_merge_failure {
return format!(
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
);
}
// Clear the blocked flag if present.
if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
}
// Clear merge_failure if present.
if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
}
// Reset retry_count to 0 (re-read the updated file, modify, write).
let updated_contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) => return format!("Failed to re-read story file after unblocking: {e}"),
};
let with_retry_reset = set_front_matter_field(&updated_contents, "retry_count", "0");
if let Err(e) = std::fs::write(path, &with_retry_reset) {
return format!("Failed to reset retry_count on **{story_id}**: {e}");
}
let mut cleared = Vec::new();
if has_blocked { cleared.push("blocked"); }
if has_merge_failure { cleared.push("merge_failure"); }
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
}
// ---------------------------------------------------------------------------
// Tests