fix: allow new plan_write after completed approved plan
When an approved plan was fully complete (all items done/blocked), plan_write blocked creating a new plan with 'Cannot remove item' error. Now checks is_complete() first — complete plans allow fresh plan creation without carrying over approved_revision or enforcing item ID preservation. Adds 4 end-to-end integration tests covering happy path, negative (in-progress still blocks), and boundary cases (all-blocked, mixed).
This commit is contained in:
@@ -847,20 +847,30 @@ pub async fn execute_plan_write<W: UiWriter>(
|
|||||||
let existing_plan = read_plan(session_id)?;
|
let existing_plan = read_plan(session_id)?;
|
||||||
|
|
||||||
if let Some(existing) = existing_plan {
|
if let Some(existing) = existing_plan {
|
||||||
// Preserve approved_revision from existing plan
|
if existing.is_complete() {
|
||||||
plan.approved_revision = existing.approved_revision;
|
// Existing plan is fully complete (all items done/blocked).
|
||||||
// Increment revision
|
// Treat the incoming plan as a fresh plan — don't carry over
|
||||||
plan.revision = existing.revision + 1;
|
// approved_revision or enforce item preservation.
|
||||||
|
debug!(
|
||||||
|
"Existing plan '{}' is complete — allowing fresh plan '{}'",
|
||||||
|
existing.plan_id, plan.plan_id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Preserve approved_revision from existing plan
|
||||||
|
plan.approved_revision = existing.approved_revision;
|
||||||
|
// Increment revision
|
||||||
|
plan.revision = existing.revision + 1;
|
||||||
|
|
||||||
// If plan was approved, ensure checks are not removed
|
// If plan was approved, ensure checks are not removed
|
||||||
if existing.is_approved() {
|
if existing.is_approved() {
|
||||||
// Verify all existing item IDs still exist
|
// Verify all existing item IDs still exist
|
||||||
for existing_item in &existing.items {
|
for existing_item in &existing.items {
|
||||||
if !plan.items.iter().any(|i| i.id == existing_item.id) {
|
if !plan.items.iter().any(|i| i.id == existing_item.id) {
|
||||||
return Ok(format!(
|
return Ok(format!(
|
||||||
"❌ Cannot remove item '{}' from approved plan. Items can only be marked blocked, not removed.",
|
"❌ Cannot remove item '{}' from approved plan. Items can only be marked blocked, not removed.",
|
||||||
existing_item.id
|
existing_item.id
|
||||||
));
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
346
crates/g3-core/tests/plan_new_after_complete_test.rs
Normal file
346
crates/g3-core/tests/plan_new_after_complete_test.rs
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
//! Plan New-After-Complete Integration Tests
|
||||||
|
//!
|
||||||
|
//! These tests verify that when an approved plan is fully complete (all items
|
||||||
|
//! done or blocked), a new plan can be written with different item IDs.
|
||||||
|
//!
|
||||||
|
//! Bug: execute_plan_write always preserves approved_revision from the existing
|
||||||
|
//! plan and blocks item removal, even when the existing plan is fully complete.
|
||||||
|
//! This prevents starting a new plan after finishing the previous one.
|
||||||
|
|
||||||
|
use g3_core::ui_writer::NullUiWriter;
|
||||||
|
use g3_core::{Agent, ToolCall};
|
||||||
|
use serial_test::serial;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Test Helpers
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
async fn create_test_agent(temp_dir: &TempDir) -> Agent<NullUiWriter> {
|
||||||
|
std::env::set_current_dir(temp_dir.path()).unwrap();
|
||||||
|
let config = g3_config::Config::default();
|
||||||
|
let ui_writer = NullUiWriter;
|
||||||
|
Agent::new(config, ui_writer).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_tool_call(tool: &str, args: serde_json::Value) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
tool: tool.to_string(),
|
||||||
|
args,
|
||||||
|
id: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: YAML for a simple plan with one item.
|
||||||
|
fn plan_yaml(plan_id: &str, item_id: &str, state: &str, with_evidence: bool) -> String {
|
||||||
|
let evidence_section = if with_evidence {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
evidence: ["src/test.rs:1"]
|
||||||
|
notes: "Done implementing""#
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"plan_id: {plan_id}
|
||||||
|
revision: 1
|
||||||
|
items:
|
||||||
|
- id: {item_id}
|
||||||
|
description: "Task for {item_id}"
|
||||||
|
state: {state}
|
||||||
|
touches: ["src/test.rs"]
|
||||||
|
checks:
|
||||||
|
happy:
|
||||||
|
desc: Works
|
||||||
|
target: test
|
||||||
|
negative:
|
||||||
|
- desc: Errors
|
||||||
|
target: test
|
||||||
|
boundary:
|
||||||
|
- desc: Edge
|
||||||
|
target: test{evidence_section}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: YAML for a plan with two items, each with independent state.
|
||||||
|
fn plan_yaml_two_items(
|
||||||
|
plan_id: &str,
|
||||||
|
id1: &str,
|
||||||
|
state1: &str,
|
||||||
|
evidence1: bool,
|
||||||
|
id2: &str,
|
||||||
|
state2: &str,
|
||||||
|
evidence2: bool,
|
||||||
|
) -> String {
|
||||||
|
let ev = |has: bool| -> String {
|
||||||
|
if has {
|
||||||
|
"\n evidence: [\"src/test.rs:1\"]\n notes: \"Done\"".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
format!(
|
||||||
|
r#"plan_id: {plan_id}
|
||||||
|
revision: 1
|
||||||
|
items:
|
||||||
|
- id: {id1}
|
||||||
|
description: "Task {id1}"
|
||||||
|
state: {state1}
|
||||||
|
touches: ["src/test.rs"]
|
||||||
|
checks:
|
||||||
|
happy: {{desc: Works, target: test}}
|
||||||
|
negative: [{{desc: Errors, target: test}}]
|
||||||
|
boundary: [{{desc: Edge, target: test}}]{ev1}
|
||||||
|
- id: {id2}
|
||||||
|
description: "Task {id2}"
|
||||||
|
state: {state2}
|
||||||
|
touches: ["src/test.rs"]
|
||||||
|
checks:
|
||||||
|
happy: {{desc: Works, target: test}}
|
||||||
|
negative: [{{desc: Errors, target: test}}]
|
||||||
|
boundary: [{{desc: Edge, target: test}}]{ev2}"#,
|
||||||
|
ev1 = ev(evidence1),
|
||||||
|
ev2 = ev(evidence2),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Happy path: new plan after completed plan should succeed
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Reproduces the bug: create plan A, approve it, mark all items done,
|
||||||
|
/// then write plan B with completely different item IDs.
|
||||||
|
/// Before the fix, this fails with "Cannot remove item 'I1' from approved plan".
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_new_plan_after_completed_plan_succeeds() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
|
||||||
|
// Create evidence file so verification doesn't complain
|
||||||
|
let src_dir = temp_dir.path().join("src");
|
||||||
|
std::fs::create_dir_all(&src_dir).unwrap();
|
||||||
|
std::fs::write(src_dir.join("test.rs"), "// test").unwrap();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(&temp_dir).await;
|
||||||
|
agent.init_session_id_for_test("new-plan-after-complete");
|
||||||
|
|
||||||
|
// Step 1: Write plan A
|
||||||
|
let write_a = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-a", "I1", "todo", false) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&write_a).await.unwrap();
|
||||||
|
assert!(result.contains("✅"), "Plan A write should succeed: {}", result);
|
||||||
|
|
||||||
|
// Step 2: Approve plan A
|
||||||
|
let approve = make_tool_call("plan_approve", serde_json::json!({}));
|
||||||
|
let result = agent.execute_tool(&approve).await.unwrap();
|
||||||
|
assert!(result.contains("approved"), "Plan A should be approved: {}", result);
|
||||||
|
|
||||||
|
// Step 3: Mark plan A item as done
|
||||||
|
let done_a = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-a", "I1", "done", true) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&done_a).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.contains("✅"),
|
||||||
|
"Marking plan A done should succeed: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 4: Write plan B with completely different item IDs
|
||||||
|
let write_b = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-b", "J1", "todo", false) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&write_b).await.unwrap();
|
||||||
|
|
||||||
|
// THIS IS THE BUG: before the fix, this returns an error about removing item I1
|
||||||
|
assert!(
|
||||||
|
result.contains("✅"),
|
||||||
|
"New plan B after completed plan A should succeed, but got: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.contains("Cannot remove item"),
|
||||||
|
"Should NOT block item removal when old plan is complete: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify plan B is stored correctly
|
||||||
|
let read = make_tool_call("plan_read", serde_json::json!({}));
|
||||||
|
let result = agent.execute_tool(&read).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.contains("plan-b"),
|
||||||
|
"Should now contain plan-b: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("J1"),
|
||||||
|
"Should contain new item J1: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Negative: in-progress approved plan still blocks item removal
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Confirms that removing items from an in-progress (not complete) approved plan
|
||||||
|
/// is still blocked — the fix must not weaken this protection.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_inprogress_approved_plan_blocks_item_removal() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let src_dir = temp_dir.path().join("src");
|
||||||
|
std::fs::create_dir_all(&src_dir).unwrap();
|
||||||
|
std::fs::write(src_dir.join("test.rs"), "// test").unwrap();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(&temp_dir).await;
|
||||||
|
agent.init_session_id_for_test("inprogress-blocks-removal");
|
||||||
|
|
||||||
|
// Write plan with two items
|
||||||
|
let write = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({
|
||||||
|
"plan": plan_yaml_two_items("plan-x", "I1", "todo", false, "I2", "todo", false)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
agent.execute_tool(&write).await.unwrap();
|
||||||
|
|
||||||
|
// Approve
|
||||||
|
let approve = make_tool_call("plan_approve", serde_json::json!({}));
|
||||||
|
agent.execute_tool(&approve).await.unwrap();
|
||||||
|
|
||||||
|
// Mark only I1 as done (I2 still todo — plan is NOT complete)
|
||||||
|
let partial = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({
|
||||||
|
"plan": plan_yaml_two_items("plan-x", "I1", "done", true, "I2", "todo", false)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&partial).await.unwrap();
|
||||||
|
assert!(result.contains("✅"), "Partial update should succeed: {}", result);
|
||||||
|
|
||||||
|
// Now try to write a new plan that removes I2 — should be BLOCKED
|
||||||
|
let remove_attempt = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-x", "I1", "done", true) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&remove_attempt).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.contains("Cannot remove item"),
|
||||||
|
"Should block removal of I2 from in-progress approved plan: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Boundary: plan where all items are blocked also allows new plan
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// A plan where every item is blocked (not done) is still "complete" per
|
||||||
|
/// is_complete() — verify a new plan can be started.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_new_plan_after_all_blocked_plan_succeeds() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let src_dir = temp_dir.path().join("src");
|
||||||
|
std::fs::create_dir_all(&src_dir).unwrap();
|
||||||
|
std::fs::write(src_dir.join("test.rs"), "// test").unwrap();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(&temp_dir).await;
|
||||||
|
agent.init_session_id_for_test("new-plan-after-all-blocked");
|
||||||
|
|
||||||
|
// Write plan with one item
|
||||||
|
let write = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-blocked", "I1", "todo", false) }),
|
||||||
|
);
|
||||||
|
agent.execute_tool(&write).await.unwrap();
|
||||||
|
|
||||||
|
// Approve
|
||||||
|
let approve = make_tool_call("plan_approve", serde_json::json!({}));
|
||||||
|
agent.execute_tool(&approve).await.unwrap();
|
||||||
|
|
||||||
|
// Mark item as blocked (not done — no evidence needed)
|
||||||
|
let block = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-blocked", "I1", "blocked", false) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&block).await.unwrap();
|
||||||
|
assert!(result.contains("✅"), "Blocking should succeed: {}", result);
|
||||||
|
|
||||||
|
// Now write a completely new plan — should succeed
|
||||||
|
let write_new = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-fresh", "K1", "todo", false) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&write_new).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.contains("✅"),
|
||||||
|
"New plan after all-blocked plan should succeed: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.contains("Cannot remove item"),
|
||||||
|
"Should NOT block item removal when old plan is fully blocked: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Boundary: completed plan with mix of done and blocked allows new plan
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// A plan with some items done and some blocked is complete — new plan allowed.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_new_plan_after_mixed_done_blocked_succeeds() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let src_dir = temp_dir.path().join("src");
|
||||||
|
std::fs::create_dir_all(&src_dir).unwrap();
|
||||||
|
std::fs::write(src_dir.join("test.rs"), "// test").unwrap();
|
||||||
|
|
||||||
|
let mut agent = create_test_agent(&temp_dir).await;
|
||||||
|
agent.init_session_id_for_test("new-plan-mixed-complete");
|
||||||
|
|
||||||
|
// Write plan with two items
|
||||||
|
let write = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({
|
||||||
|
"plan": plan_yaml_two_items("plan-mix", "I1", "todo", false, "I2", "todo", false)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
agent.execute_tool(&write).await.unwrap();
|
||||||
|
|
||||||
|
// Approve
|
||||||
|
let approve = make_tool_call("plan_approve", serde_json::json!({}));
|
||||||
|
agent.execute_tool(&approve).await.unwrap();
|
||||||
|
|
||||||
|
// Mark I1 done, I2 blocked — plan is complete
|
||||||
|
let complete = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({
|
||||||
|
"plan": plan_yaml_two_items("plan-mix", "I1", "done", true, "I2", "blocked", false)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&complete).await.unwrap();
|
||||||
|
assert!(result.contains("✅"), "Completing plan should succeed: {}", result);
|
||||||
|
|
||||||
|
// Write a new plan with different IDs
|
||||||
|
let write_new = make_tool_call(
|
||||||
|
"plan_write",
|
||||||
|
serde_json::json!({ "plan": plan_yaml("plan-next", "N1", "todo", false) }),
|
||||||
|
);
|
||||||
|
let result = agent.execute_tool(&write_new).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
result.contains("✅"),
|
||||||
|
"New plan after mixed done/blocked should succeed: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user