-
Notifications
You must be signed in to change notification settings - Fork 0
fix: three dispatch-reliability bugs found dogfooding autonomous work #413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
91bcd1e
4895ad8
2447bfd
0d91bb0
f41c575
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,18 @@ | ||||||||
| use super::*; | ||||||||
|
|
||||||||
| /// The project's `ready-for-work` label id, if the project has that label at | ||||||||
| /// all — skipped (returns `None`) rather than creating it out of nowhere. | ||||||||
| /// Shared by both branches of `handoff_impl` below: a brand-new handed-off | ||||||||
| /// item, and an existing item that's safe to queue (see the `Some(id)` | ||||||||
| /// branch's own comment for what "safe" means there). | ||||||||
| fn ready_label_id(conn: &rusqlite::Connection, project_id: &str) -> Option<String> { | ||||||||
| agentflare_backend::label::list_by_project(conn, project_id) | ||||||||
| .ok()? | ||||||||
| .into_iter() | ||||||||
| .find(|l| l.name == crate::supervisor::READY_LABEL) | ||||||||
| .map(|l| l.id) | ||||||||
| } | ||||||||
|
|
||||||||
| impl AgentflareMcp { | ||||||||
| pub fn handoff_impl( | ||||||||
| &self, | ||||||||
|
|
@@ -101,7 +114,34 @@ impl AgentflareMcp { | |||||||
| assignee_agent: Some(recipient.clone()), | ||||||||
| ..Default::default() | ||||||||
| }; | ||||||||
| agentflare_backend::item::update(conn, id, input).map_err(map_backend_err)? | ||||||||
| let item = agentflare_backend::item::update(conn, id, input) | ||||||||
| .map_err(map_backend_err)?; | ||||||||
| // Queue it for autonomous dispatch too, same as the | ||||||||
| // brand-new-item path below — but only when it's safe: | ||||||||
| // genuinely fresh (backlog/unstarted/triage) and nobody | ||||||||
| // has ever claimed it. An explicit `item_id` handoff onto | ||||||||
| // something already claimed, in progress, in review, or | ||||||||
| // completed must NOT be silently re-queued — same danger | ||||||||
| // the reply/continuation branch below already guards | ||||||||
| // against, just reached via a different path (a caller | ||||||||
| // passing `item_id` directly instead of relying on | ||||||||
| // name/thread matching). Without this, `handoff` onto an | ||||||||
| // existing item only ever set the assignee — queuing it | ||||||||
| // needed a separate `item add_label` call every time. | ||||||||
| let state = agentflare_backend::state::get(conn, &item.state_id) | ||||||||
| .map_err(map_backend_err)?; | ||||||||
| let never_claimed = | ||||||||
| agentflare_backend::claim::current_owner(conn, id).is_none(); | ||||||||
| if never_claimed | ||||||||
| && matches!( | ||||||||
| state.group_name.as_str(), | ||||||||
| "backlog" | "unstarted" | "triage" | ||||||||
| ) | ||||||||
| && let Some(ready_id) = ready_label_id(conn, &project.id) | ||||||||
| { | ||||||||
| let _ = agentflare_backend::item::add_label(conn, id, &ready_id); | ||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Propagate the ready-label attachment error. When the ready label exists, Proposed fix- let _ = agentflare_backend::item::add_label(conn, id, &ready_id);
+ agentflare_backend::item::add_label(conn, id, &ready_id)
+ .map_err(map_backend_err)?;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||
| } | ||||||||
| item | ||||||||
| } | ||||||||
| None => { | ||||||||
| // Reuse an existing open item already assigned to the | ||||||||
|
|
@@ -159,15 +199,7 @@ impl AgentflareMcp { | |||||||
| // may already be claimed, in progress, or done, and | ||||||||
| // silently re-queuing those for dispatch would be | ||||||||
| // wrong. | ||||||||
| let ready_label_id = | ||||||||
| agentflare_backend::label::list_by_project(conn, &project.id) | ||||||||
| .ok() | ||||||||
| .and_then(|labels| { | ||||||||
| labels | ||||||||
| .into_iter() | ||||||||
| .find(|l| l.name == crate::supervisor::READY_LABEL) | ||||||||
| }) | ||||||||
| .map(|l| l.id); | ||||||||
| let ready_label_id = ready_label_id(conn, &project.id); | ||||||||
| let input = agentflare_backend::item::CreateItem { | ||||||||
| project_id: project.id.clone(), | ||||||||
| state_id, | ||||||||
|
|
@@ -661,7 +693,7 @@ mod tests { | |||||||
| } | ||||||||
|
|
||||||||
| #[test] | ||||||||
| fn a_reply_to_an_existing_item_is_not_labeled_ready_for_work() { | ||||||||
| fn a_reply_to_an_already_claimed_item_is_not_relabeled_ready_for_work() { | ||||||||
| let (_tmp, mcp) = test_mcp(); | ||||||||
| seed_ready_for_work_label(&mcp); | ||||||||
|
|
||||||||
|
|
@@ -671,8 +703,32 @@ mod tests { | |||||||
| .as_str() | ||||||||
| .unwrap() | ||||||||
| .to_string(); | ||||||||
| // A human clears the label after picking it up manually, same as the | ||||||||
| // supervisor's own discovery tick would once it dispatches the item. | ||||||||
| // The recipient actually claims it — moves to "started", creates a | ||||||||
| // claim row — same as the supervisor's own discovery tick dispatch | ||||||||
| // would once it picks the item up. This is the real condition the | ||||||||
| // explicit-`item_id` branch must protect against, not merely the | ||||||||
| // label being absent (see the sibling test below: a still-fresh | ||||||||
| // item with no label DOES get relabeled now, on purpose). Claims | ||||||||
| // directly against the backend (not through the MCP `item_claim` | ||||||||
| // wrapper, whose owner resolves ambiently from process identity — | ||||||||
| // not controllable here) with an owner matching the assignee | ||||||||
| // (`claude-code`), since `item::claim`'s handoff-freeze rule blocks | ||||||||
| // a mismatched owner from acquiring a freshly handed-off item. | ||||||||
| mcp.with_backend_db(|conn| { | ||||||||
| agentflare_backend::item::claim( | ||||||||
| conn, | ||||||||
| &item_id, | ||||||||
| "claude-code:test", | ||||||||
| db_kit::ids::now(), | ||||||||
| 1800, | ||||||||
| ) | ||||||||
| }) | ||||||||
| .unwrap() | ||||||||
| .unwrap(); | ||||||||
| // The real dispatch path (`supervisor::dispatch_item`) strips the | ||||||||
| // ready-for-work label the moment it enqueues a job, well before | ||||||||
| // the agent ever claims anything — mirror that here so this test | ||||||||
| // reflects the label state a real in-flight item actually has. | ||||||||
| mcp.with_backend_db(|conn| { | ||||||||
| let label_ids = agentflare_backend::item::list_labels(conn, &item_id).unwrap(); | ||||||||
| for id in label_ids { | ||||||||
|
|
@@ -681,8 +737,8 @@ mod tests { | |||||||
| }) | ||||||||
| .unwrap(); | ||||||||
|
|
||||||||
| // A reply (item_id set) must not silently re-queue an item that may | ||||||||
| // already be claimed, in progress, or done. | ||||||||
| // A reply (item_id set) must not silently re-queue an item that's | ||||||||
| // already claimed, in progress, or done. | ||||||||
| let reply = HandoffRequest { | ||||||||
| item_id: Some(item_id.clone()), | ||||||||
| completed: "more".to_string(), | ||||||||
|
|
@@ -693,4 +749,42 @@ mod tests { | |||||||
|
|
||||||||
| assert!(item_label_names(&mcp, &item_id).is_empty()); | ||||||||
| } | ||||||||
|
|
||||||||
| #[test] | ||||||||
| fn an_explicit_item_id_handoff_labels_a_still_fresh_unclaimed_item() { | ||||||||
| let (_tmp, mcp) = test_mcp(); | ||||||||
| seed_ready_for_work_label(&mcp); | ||||||||
|
|
||||||||
| // First handoff creates the item, then a human clears the label | ||||||||
| // without actually claiming it (e.g. picked up by hand outside the | ||||||||
| // autonomous queue, or just never got labeled the first time). | ||||||||
| let first = mcp.handoff_impl(base_request()).unwrap(); | ||||||||
| let item_id = serde_json::from_str::<serde_json::Value>(&first).unwrap()["item_id"] | ||||||||
| .as_str() | ||||||||
| .unwrap() | ||||||||
| .to_string(); | ||||||||
| mcp.with_backend_db(|conn| { | ||||||||
| let label_ids = agentflare_backend::item::list_labels(conn, &item_id).unwrap(); | ||||||||
| for id in label_ids { | ||||||||
| agentflare_backend::item::remove_label(conn, &item_id, &id).unwrap(); | ||||||||
| } | ||||||||
| }) | ||||||||
| .unwrap(); | ||||||||
|
|
||||||||
| // A second handoff onto the same still-fresh, never-claimed item | ||||||||
| // must queue it for dispatch — the whole point of this change is | ||||||||
| // that a single `handoff(item_id=...)` call is enough, no separate | ||||||||
| // `item add_label` call required. | ||||||||
| let reply = HandoffRequest { | ||||||||
| item_id: Some(item_id.clone()), | ||||||||
| completed: "more".to_string(), | ||||||||
| remaining: "less".to_string(), | ||||||||
| ..base_request() | ||||||||
| }; | ||||||||
| mcp.handoff_impl(reply).unwrap(); | ||||||||
|
|
||||||||
| assert!( | ||||||||
| item_label_names(&mcp, &item_id).contains(&crate::supervisor::READY_LABEL.to_string()) | ||||||||
| ); | ||||||||
| } | ||||||||
| } | ||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Separate commit existence from push/PR divergence.
branch_divergedreturnsfalsewhen the branch has zero commits, when target already has identical content after a squash merge, and when a Git query fails.item_donetreats all three cases as an empty run. It then cleans the worktree, releases the claim, and leaves the item unchanged. A branch with committed work that is already present on target cannot complete through this path.crates/flare-git-core/src/worktree.rs#L454-L466: add a fallible helper that reports whethertarget_branch..branchhas commits. Keepbranch_divergedfor push and PR suppression.src/worktree.rs#L25-L34: expose the fallible commit-existence helper.src/mcp_server/item.rs#L645-L684: classify an item as unchanged only when the commit check succeeds and reports zero commits. Preserve the claim and worktree when the Git check fails.src/mcp_server/tests/action_tests.rs#L357-L405: add coverage for a task branch with committed content already applied to target. Assert completion. Also assert that the true zero-commit case releases the claim for a retry.📍 Affects 4 files
crates/flare-git-core/src/worktree.rs#L454-L466(this comment)src/worktree.rs#L25-L34src/mcp_server/item.rs#L645-L684src/mcp_server/tests/action_tests.rs#L357-L405🤖 Prompt for AI Agents