//! Tests for single-instance concurrency, TOCTOU races, duplicate-stage
//! prevention, and pipeline-stage mismatch guards in `AgentPool::start_agent`.

use super::super::AgentPool;
use crate::agents::{AgentEvent, AgentStatus};

// ── start_agent single-instance concurrency tests ─────────────────────────

#[tokio::test]
async fn start_agent_rejects_when_same_agent_already_running_on_another_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap();

    let pool = AgentPool::new_test(3001);
    pool.inject_test_agent("story-a", "qa", AgentStatus::Running);

    let result = pool
        .start_agent(root, "story-b", Some("qa"), None, None)
        .await;

    assert!(
        result.is_err(),
        "start_agent should fail when qa is already running on another story"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("already running") || err.contains("becomes available"),
        "error message should explain why: got '{err}'"
    );
}

#[tokio::test]
async fn start_agent_allows_new_story_when_previous_run_is_completed() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap();

    let pool = AgentPool::new_test(3001);
    pool.inject_test_agent("story-a", "qa", AgentStatus::Completed);

    let result = pool
        .start_agent(root, "story-b", Some("qa"), None, None)
        .await;

    if let Err(ref e) = result {
        assert!(
            !e.contains("already running") && !e.contains("becomes available"),
            "completed agent must not trigger the concurrency guard: got '{e}'"
        );
    }
}

// ── bug 118: pending entry cleanup on start_agent failure ────────────────

#[tokio::test]
async fn start_agent_cleans_up_pending_entry_on_failure() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    let upcoming = root.join(".huskies/work/1_backlog");
    fs::create_dir_all(&upcoming).unwrap();
    fs::write(upcoming.join("50_story_test.md"), "---\nname: Test\n---\n").unwrap();

    let pool = AgentPool::new_test(3099);

    let result = pool
        .start_agent(root, "50_story_test", Some("coder-1"), None, None)
        .await;

    assert!(
        result.is_ok(),
        "start_agent should return Ok(Pending) immediately: {:?}",
        result.err()
    );
    assert_eq!(
        result.unwrap().status,
        AgentStatus::Pending,
        "initial status must be Pending"
    );

    let final_info = pool
        .wait_for_agent("50_story_test", "coder-1", 5000)
        .await
        .expect("wait_for_agent should not time out");
    assert_eq!(
        final_info.status,
        AgentStatus::Failed,
        "agent must transition to Failed after worktree creation error"
    );

    let agents = pool.agents.lock().await;
    let failed_entry = agents
        .values()
        .find(|a| a.agent_name == "coder-1" && a.status == AgentStatus::Failed);
    assert!(
        failed_entry.is_some(),
        "agent pool must retain a Failed entry so the UI can show the error state"
    );
    drop(agents);

    let events = pool
        .drain_events("50_story_test", "coder-1")
        .await
        .expect("drain_events should succeed");
    let has_error_event = events.iter().any(|e| matches!(e, AgentEvent::Error { .. }));
    assert!(
        has_error_event,
        "event_log must contain AgentEvent::Error after worktree creation fails"
    );
}

#[tokio::test]
async fn start_agent_guard_does_not_remove_running_entry() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(sk_dir.join("project.toml"), "[[agent]]\nname = \"qa\"\n").unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("story-x", "qa", AgentStatus::Running);

    let result = pool
        .start_agent(root, "story-y", Some("qa"), None, None)
        .await;

    assert!(result.is_err());
    let err = result.unwrap_err();
    assert!(
        err.contains("already running") || err.contains("becomes available"),
        "running entry must survive: got '{err}'"
    );
}

// ── TOCTOU race-condition regression tests (story 132) ───────────────────

#[tokio::test]
async fn toctou_pending_entry_blocks_same_agent_on_different_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("86_story_foo", "coder-1", AgentStatus::Pending);

    let result = pool
        .start_agent(root, "130_story_bar", Some("coder-1"), None, None)
        .await;

    assert!(result.is_err(), "second start_agent must be rejected");
    let err = result.unwrap_err();
    assert!(
        err.contains("already running") || err.contains("becomes available"),
        "expected concurrency-rejection message, got: '{err}'"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn toctou_concurrent_start_agent_same_agent_exactly_one_concurrency_rejection() {
    use std::fs;
    use std::sync::Arc;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().to_path_buf();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "[[agent]]\nname = \"coder-1\"\n",
    )
    .unwrap();
    fs::write(
        root.join(".huskies/work/1_backlog/86_story_foo.md"),
        "---\nname: Foo\n---\n",
    )
    .unwrap();
    fs::write(
        root.join(".huskies/work/1_backlog/130_story_bar.md"),
        "---\nname: Bar\n---\n",
    )
    .unwrap();

    let pool = Arc::new(AgentPool::new_test(3099));

    let pool1 = pool.clone();
    let root1 = root.clone();
    let t1 = tokio::spawn(async move {
        pool1
            .start_agent(&root1, "86_story_foo", Some("coder-1"), None, None)
            .await
    });

    let pool2 = pool.clone();
    let root2 = root.clone();
    let t2 = tokio::spawn(async move {
        pool2
            .start_agent(&root2, "130_story_bar", Some("coder-1"), None, None)
            .await
    });

    let (r1, r2) = tokio::join!(t1, t2);
    let r1 = r1.unwrap();
    let r2 = r2.unwrap();

    let concurrency_rejections = [&r1, &r2]
        .iter()
        .filter(|r| {
            r.as_ref()
                .is_err_and(|e| e.contains("already running") || e.contains("becomes available"))
        })
        .count();

    assert_eq!(
        concurrency_rejections, 1,
        "exactly one call must be rejected by the concurrency check; \
         got r1={r1:?} r2={r2:?}"
    );
}

// ── story-230: prevent duplicate stage agents on same story ───────────────

#[tokio::test]
async fn start_agent_rejects_second_coder_stage_on_same_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running);

    let result = pool
        .start_agent(root, "42_story_foo", Some("coder-2"), None, None)
        .await;

    assert!(
        result.is_err(),
        "second coder on same story must be rejected"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("same pipeline stage"),
        "error must mention same pipeline stage, got: '{err}'"
    );
    assert!(
        err.contains("coder-1") && err.contains("coder-2"),
        "error must name both agents, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_rejects_second_qa_stage_on_same_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n\n\
         [[agent]]\nname = \"qa-2\"\nstage = \"qa\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("55_story_bar", "qa-1", AgentStatus::Running);

    let result = pool
        .start_agent(root, "55_story_bar", Some("qa-2"), None, None)
        .await;

    assert!(result.is_err(), "second qa on same story must be rejected");
    let err = result.unwrap_err();
    assert!(
        err.contains("same pipeline stage"),
        "error must mention same pipeline stage, got: '{err}'"
    );
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn start_agent_concurrent_two_coders_same_story_exactly_one_stage_rejection() {
    use std::fs;
    use std::sync::Arc;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().to_path_buf();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n",
    )
    .unwrap();
    fs::write(
        root.join(".huskies/work/2_current/42_story_foo.md"),
        "---\nname: Foo\n---\n",
    )
    .unwrap();

    let pool = Arc::new(AgentPool::new_test(3099));

    let pool1 = pool.clone();
    let root1 = root.clone();
    let t1 = tokio::spawn(async move {
        pool1
            .start_agent(&root1, "42_story_foo", Some("coder-1"), None, None)
            .await
    });

    let pool2 = pool.clone();
    let root2 = root.clone();
    let t2 = tokio::spawn(async move {
        pool2
            .start_agent(&root2, "42_story_foo", Some("coder-2"), None, None)
            .await
    });

    let (r1, r2) = tokio::join!(t1, t2);
    let r1 = r1.unwrap();
    let r2 = r2.unwrap();

    let stage_rejections = [&r1, &r2]
        .iter()
        .filter(|r| r.as_ref().is_err_and(|e| e.contains("same pipeline stage")))
        .count();

    assert_eq!(
        stage_rejections, 1,
        "exactly one call must be rejected by the stage-conflict check; \
         got r1={r1:?} r2={r2:?}"
    );
}

#[tokio::test]
async fn start_agent_two_coders_different_stories_not_blocked_by_stage_check() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n",
    )
    .unwrap();
    fs::write(
        root.join(".huskies/work/1_backlog/99_story_baz.md"),
        "---\nname: Baz\n---\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("42_story_foo", "coder-1", AgentStatus::Running);

    let result = pool
        .start_agent(root, "99_story_baz", Some("coder-2"), None, None)
        .await;

    if let Err(ref e) = result {
        assert!(
            !e.contains("same pipeline stage"),
            "stage-conflict guard must not fire for agents on different stories; \
             got: '{e}'"
        );
    }
}

// ── bug 312: stage-pipeline mismatch guard in start_agent ──────────────

#[tokio::test]
async fn start_agent_rejects_mergemaster_on_coding_stage_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "310_story_foo",
        "2_current",
        "---\nname: Foo\n---\n",
        crate::db::ItemMeta::named("Foo"),
    );

    let pool = AgentPool::new_test(3099);
    let result = pool
        .start_agent(root, "310_story_foo", Some("mergemaster"), None, None)
        .await;

    assert!(
        result.is_err(),
        "mergemaster must not be assigned to a story in coding stage"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("stage") && err.contains("coding"),
        "error must mention stage mismatch, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_rejects_coder_on_qa_stage_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"qa\"\nstage = \"qa\"\n",
    )
    .unwrap();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "8842_story_qa_guard",
        "3_qa",
        "---\nname: QA Guard\n---\n",
        crate::db::ItemMeta::named("QA Guard"),
    );

    let pool = AgentPool::new_test(3099);
    let result = pool
        .start_agent(root, "8842_story_qa_guard", Some("coder-1"), None, None)
        .await;

    assert!(
        result.is_err(),
        "coder must not be assigned to a story in qa stage"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("stage") && err.contains("qa"),
        "error must mention stage mismatch, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_rejects_qa_on_merge_stage_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"qa\"\nstage = \"qa\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "55_story_baz",
        "4_merge",
        "---\nname: Baz\n---\n",
        crate::db::ItemMeta::named("Baz"),
    );

    let pool = AgentPool::new_test(3099);
    let result = pool
        .start_agent(root, "55_story_baz", Some("qa"), None, None)
        .await;

    assert!(
        result.is_err(),
        "qa must not be assigned to a story in merge stage"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("stage") && err.contains("merge"),
        "error must mention stage mismatch, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_allows_supervisor_on_any_stage() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/2_current")).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"supervisor\"\nstage = \"other\"\n",
    )
    .unwrap();
    fs::write(
        sk_dir.join("work/2_current/77_story_sup.md"),
        "---\nname: Sup\n---\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    let result = pool
        .start_agent(root, "77_story_sup", Some("supervisor"), None, None)
        .await;

    match result {
        Ok(_) => {}
        Err(e) => {
            assert!(
                !e.contains("stage:") || !e.contains("cannot be assigned"),
                "supervisor should not be rejected for stage mismatch, got: '{e}'"
            );
        }
    }
}

#[tokio::test]
async fn start_agent_allows_correct_stage_agent() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();
    fs::write(
        sk_dir.join("work/4_merge/88_story_ok.md"),
        "---\nname: OK\n---\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    let result = pool
        .start_agent(root, "88_story_ok", Some("mergemaster"), None, None)
        .await;

    match result {
        Ok(_) => {}
        Err(e) => {
            assert!(
                !e.contains("cannot be assigned"),
                "mergemaster on 4_merge/ story should not fail stage check, got: '{e}'"
            );
        }
    }
}

// ── story-1100: cross-stage LLM agent rejection ─────────────────────────

#[tokio::test]
async fn start_agent_rejects_mergemaster_when_coder_running_same_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("999_story_cross", "coder-1", AgentStatus::Running);

    let result = pool
        .start_agent(root, "999_story_cross", Some("mergemaster"), None, None)
        .await;

    assert!(
        result.is_err(),
        "mergemaster must be rejected when coder-1 is still running on same story"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("active LLM agent") || err.contains("stale agent"),
        "error must mention active LLM agent conflict, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_rejects_coder_when_mergemaster_running_same_story() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("888_story_cross2", "mergemaster", AgentStatus::Running);

    let result = pool
        .start_agent(root, "888_story_cross2", Some("coder-1"), None, None)
        .await;

    assert!(
        result.is_err(),
        "coder-1 must be rejected when mergemaster is running on same story"
    );
    let err = result.unwrap_err();
    assert!(
        err.contains("active LLM agent") || err.contains("stale agent"),
        "error must mention active LLM agent conflict, got: '{err}'"
    );
}

#[tokio::test]
async fn start_agent_cross_stage_does_not_block_different_stories() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
    fs::write(
        root.join(".huskies/project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();
    fs::write(
        root.join(".huskies/work/1_backlog/777_story_other.md"),
        "---\nname: Other\n---\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    // mergemaster running on story-x should NOT block coder on story-y
    pool.inject_test_agent("111_story_x", "mergemaster", AgentStatus::Running);

    let result = pool
        .start_agent(root, "777_story_other", Some("coder-1"), None, None)
        .await;

    if let Err(ref e) = result {
        assert!(
            !e.contains("active LLM agent") && !e.contains("stale agent"),
            "cross-stage guard must not fire for agents on different stories, got: '{e}'"
        );
    }
}

#[tokio::test]
async fn reconcile_canonical_agents_stops_stale_coder_in_qa_stage() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    // Write story to CRDT in QA stage: canonical = Qa, but coder-1 is Running.
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "777_story_reconcile",
        "qa",
        "---\nname: Reconcile Test\n---\n",
        crate::db::ItemMeta::named("Reconcile Test"),
    );

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("777_story_reconcile", "coder-1", AgentStatus::Running);

    let before = pool.list_agents().await.unwrap();
    assert!(
        before.iter().any(|a| a.agent_name == "coder-1"
            && matches!(a.status, AgentStatus::Running | AgentStatus::Pending)),
        "coder-1 should be Running before reconciliation"
    );

    pool.reconcile_canonical_agents(root).await;

    let after = pool.list_agents().await.unwrap();
    let still_active = after.iter().any(|a| {
        a.story_id == "777_story_reconcile"
            && a.agent_name == "coder-1"
            && matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
    });
    assert!(
        !still_active,
        "reconciler must have stopped coder-1 (CRDT stage is QA, coder is wrong stage)"
    );
}

#[tokio::test]
async fn reconcile_canonical_agents_leaves_correct_stage_agent_alone() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
    )
    .unwrap();

    // Story is in coding stage: canonical = Coder. coder-1 is correct.
    crate::db::ensure_content_store();
    crate::db::write_item_with_content(
        "555_story_correct",
        "coding",
        "---\nname: Correct Stage\n---\n",
        crate::db::ItemMeta::named("Correct Stage"),
    );

    let pool = AgentPool::new_test(3099);
    pool.inject_test_agent("555_story_correct", "coder-1", AgentStatus::Running);

    pool.reconcile_canonical_agents(root).await;

    let after = pool.list_agents().await.unwrap();
    let still_active = after.iter().any(|a| {
        a.story_id == "555_story_correct"
            && a.agent_name == "coder-1"
            && matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
    });
    assert!(
        still_active,
        "reconciler must NOT stop coder-1 when it matches the canonical stage"
    );
}

/// Regression test for story 1100: a stale coder left running after a stage
/// transition blocks both a same-stage coder and a cross-stage mergemaster.
/// The periodic reconciler stops the stale coder, after which the pool no
/// longer has a cross-stage conflict.
#[tokio::test]
async fn regression_1100_stale_coder_blocks_mergemaster_then_reconciler_clears() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(&sk_dir).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"coder-2\"\nstage = \"coder\"\n\n\
         [[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3099);
    // Simulate coder-1 still Running after the story advanced past the coding stage.
    pool.inject_test_agent("1100_reg", "coder-1", AgentStatus::Running);

    // coder-2 blocked by same-stage check (both are Coder stage)
    let r1 = pool
        .start_agent(root, "1100_reg", Some("coder-2"), None, None)
        .await;
    assert!(r1.is_err(), "coder-2 must be rejected by same-stage guard");
    assert!(
        r1.unwrap_err().contains("same pipeline stage"),
        "same-stage check must fire for coder-2"
    );

    // mergemaster blocked by cross-stage LLM guard (coder-1 is a different LLM stage)
    let r2 = pool
        .start_agent(root, "1100_reg", Some("mergemaster"), None, None)
        .await;
    assert!(
        r2.is_err(),
        "mergemaster must be rejected because coder-1 (different LLM stage) is still running"
    );
    let r2_err = r2.unwrap_err();
    assert!(
        r2_err.contains("active LLM agent") || r2_err.contains("stale agent"),
        "cross-stage rejection expected, got: '{r2_err}'"
    );

    // Reconciler: story "1100_reg" has no CRDT entry → canonical = None → stop coder-1.
    pool.reconcile_canonical_agents(root).await;

    // coder-1 must be gone from the active pool.
    let remaining = pool.list_agents().await.unwrap();
    assert!(
        !remaining.iter().any(|a| {
            a.story_id == "1100_reg"
                && a.agent_name == "coder-1"
                && matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
        }),
        "reconciler must have removed stale coder-1 from the active pool"
    );
}

/// Bug 502: when start_agent is called for a non-Coder agent (mergemaster
/// or qa) on a story that's in 4_merge/, the unconditional
/// move_story_to_current at the top of start_agent must NOT fire — even
/// when a stale split-brain shadow of the story exists in 1_backlog/.
///
/// Pre-fix behaviour: move_story_to_current would find the 1_backlog
/// shadow and move it to 2_current/. find_active_story_stage would then
/// report 2_current/, the stage check would expect a Coder-stage agent,
/// and mergemaster would be rejected — leaving the story in 2_current/
/// to be picked up by the next auto-assign tick as a coder. Infinite loop.
/// Observed live on 2026-04-09 against story 478.
#[tokio::test]
async fn start_agent_does_not_demote_merge_stage_story_with_backlog_shadow() {
    use std::fs;

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path();

    let sk_dir = root.join(".huskies");
    fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
    fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap();
    fs::write(
        sk_dir.join("project.toml"),
        "[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
    )
    .unwrap();
    // Real copy in 4_merge/ (where the story actually is per the DB).
    fs::write(
        sk_dir.join("work/4_merge/502_story_split_brain.md"),
        "---\nname: Split Brain\n---\n",
    )
    .unwrap();
    // Stale split-brain shadow in 1_backlog/ (post-491/492 migration
    // artifact — the filesystem shadow that bit us in production).
    fs::write(
        sk_dir.join("work/1_backlog/502_story_split_brain.md"),
        "---\nname: Split Brain\n---\n",
    )
    .unwrap();

    let pool = AgentPool::new_test(3098);
    let result = pool
        .start_agent(
            root,
            "502_story_split_brain",
            Some("mergemaster"),
            None,
            None,
        )
        .await;

    // Stage check must not reject mergemaster.
    if let Err(ref e) = result {
        assert!(
            !e.contains("cannot be assigned"),
            "mergemaster on 4_merge/ story must not fail stage check even \
             when a 1_backlog shadow exists, got: '{e}'"
        );
    }

    // Critical: the story must still be in 4_merge/ after the call.
    // Before the fix, line 53 of start.rs would have demoted it to
    // 2_current/ via move_story_to_current finding the 1_backlog shadow.
    assert!(
        sk_dir
            .join("work/4_merge/502_story_split_brain.md")
            .exists(),
        "story must still be in 4_merge/ after start_agent(mergemaster, ...)"
    );
    assert!(
        !sk_dir
            .join("work/2_current/502_story_split_brain.md")
            .exists(),
        "story must NOT have been demoted to 2_current/ — that's bug 502"
    );
}
