diff --git a/crates/agentflare-backend/src/db.rs b/crates/agentflare-backend/src/db.rs index 593a268d..e2fbf8ae 100644 --- a/crates/agentflare-backend/src/db.rs +++ b/crates/agentflare-backend/src/db.rs @@ -18,6 +18,7 @@ const MIGRATION_LIST: &[M<'static>] = &[ M::up(include_str!("migrations/0007_ask_events.sql")), M::up(include_str!("migrations/0008_bridge_repos.sql")), M::up(include_str!("migrations/0009_vent_escalation.sql")), + M::up(include_str!("migrations/0010_project_dirs.sql")), ]; const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST); diff --git a/crates/agentflare-backend/src/lib.rs b/crates/agentflare-backend/src/lib.rs index 07f09d82..3dbf2b7e 100644 --- a/crates/agentflare-backend/src/lib.rs +++ b/crates/agentflare-backend/src/lib.rs @@ -9,6 +9,7 @@ pub mod events; pub mod item; pub mod label; pub mod project; +pub mod project_dir; pub mod state; pub mod vent; pub mod webhook; diff --git a/crates/agentflare-backend/src/migrations/0010_project_dirs.sql b/crates/agentflare-backend/src/migrations/0010_project_dirs.sql new file mode 100644 index 00000000..8e204675 --- /dev/null +++ b/crates/agentflare-backend/src/migrations/0010_project_dirs.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS project_dirs ( + project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + folder_path TEXT NOT NULL, + updated_at INTEGER NOT NULL +); diff --git a/crates/agentflare-backend/src/project_dir.rs b/crates/agentflare-backend/src/project_dir.rs new file mode 100644 index 00000000..98345e5a --- /dev/null +++ b/crates/agentflare-backend/src/project_dir.rs @@ -0,0 +1,112 @@ +use crate::error::Result; +use rusqlite::{Connection, params}; + +/// One project's on-disk repo root — the reverse of `.agentflare/project.json` +/// (folder → project), indexed by project instead so a process with no +/// reliable cwd of its own (the daemon's background discovery loop) can +/// enumerate every project's folder it should operate against. Refreshed by +/// `resolve_project` wherever an agentflare CLI/MCP call runs inside a +/// linked repo. Unlike `bridge_repo`, not limited to GitHub-hosted repos — +/// every linked project gets a row here, regardless of its remote. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ProjectDir { + pub project_id: String, + pub folder_path: String, + pub updated_at: i64, +} + +fn from_row(row: &rusqlite::Row) -> rusqlite::Result { + Ok(ProjectDir { + project_id: row.get(0)?, + folder_path: row.get(1)?, + updated_at: row.get(2)?, + }) +} + +pub fn upsert(conn: &Connection, project_id: &str, folder_path: &str, now: i64) -> Result<()> { + conn.execute( + "INSERT INTO project_dirs (project_id, folder_path, updated_at) + VALUES (?1, ?2, ?3) + ON CONFLICT(project_id) DO UPDATE SET + folder_path = excluded.folder_path, + updated_at = excluded.updated_at", + params![project_id, folder_path, now], + )?; + Ok(()) +} + +pub fn list(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT project_id, folder_path, updated_at FROM project_dirs ORDER BY project_id", + )?; + let rows = stmt.query_map([], from_row)?; + Ok(rows.collect::>>()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seed_project(conn: &Connection, name: &str) -> String { + let workspace = crate::workspace::create( + conn, + crate::workspace::CreateWorkspace { + name: name.into(), + slug: name.into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let project = crate::project::create( + conn, + crate::project::CreateProject { + workspace_id: workspace.id, + name: name.into(), + identifier: name.into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + project.id + } + + #[test] + fn upsert_then_list_round_trips() { + let conn = crate::db::open_in_memory().unwrap(); + let pid = seed_project(&conn, "proj"); + upsert(&conn, &pid, "/home/avihs/projects/agentflare", 100).unwrap(); + + let rows = list(&conn).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].project_id, pid); + assert_eq!(rows[0].folder_path, "/home/avihs/projects/agentflare"); + assert_eq!(rows[0].updated_at, 100); + } + + #[test] + fn upsert_on_existing_project_updates_in_place() { + let conn = crate::db::open_in_memory().unwrap(); + let pid = seed_project(&conn, "proj"); + upsert(&conn, &pid, "/old/path", 100).unwrap(); + upsert(&conn, &pid, "/new/path", 200).unwrap(); + + let rows = list(&conn).unwrap(); + assert_eq!(rows.len(), 1, "same project must not create a second row"); + assert_eq!(rows[0].folder_path, "/new/path"); + assert_eq!(rows[0].updated_at, 200); + } + + #[test] + fn list_returns_every_registered_project() { + let conn = crate::db::open_in_memory().unwrap(); + let p1 = seed_project(&conn, "one"); + let p2 = seed_project(&conn, "two"); + upsert(&conn, &p1, "/repo/one", 1).unwrap(); + upsert(&conn, &p2, "/repo/two", 2).unwrap(); + + let rows = list(&conn).unwrap(); + assert_eq!(rows.len(), 2); + } +} diff --git a/src/cli/work.rs b/src/cli/work.rs index 261fb9d3..98ec8bfc 100644 --- a/src/cli/work.rs +++ b/src/cli/work.rs @@ -46,6 +46,15 @@ pub struct WorkArgs { /// Channel recipient for a handoff artifact on outcome. #[arg(long)] pub notify: Option, + /// The claimed item's own project directory, distinct from this + /// process's cwd — set by `WorkItemExecutor` from the job args the + /// supervisor's `dispatch_item` enqueues (item #63), so a daemon + /// dispatching an item from a different project than its own cwd still + /// claims/worktrees against the right repo. Not a CLI flag: a human + /// running `agentflare work` directly is already standing in the + /// right repo, same as before. + #[arg(skip)] + pub repo_root: Option, } /// Cap on how much of the latest handoff asset's content gets inlined into @@ -487,8 +496,17 @@ fn classify_and_cooldown(agent: &str, failure_message: &str) -> Option { /// progress captured into that job's own log file — the exact same file the /// dashboard already tails for subprocess-dispatched jobs — rather than only /// working when there's a real subprocess's stdout to capture. +/// +/// `args.repo_root`, when set (daemon dispatch — see `WorkItemExecutor`), +/// scopes project/worktree resolution to the claimed item's own project +/// directory instead of this process's cwd (item #63) — a human running +/// `agentflare work` directly leaves it unset and keeps the prior +/// cwd-resolved behavior. pub(crate) fn execute_work(args: WorkArgs, log: &mut dyn std::io::Write) -> WorkOutcome { - let mcp = AgentflareMcp::default(); + let mcp = match args.repo_root.clone() { + Some(root) => AgentflareMcp::for_project_dir(root), + None => AgentflareMcp::default(), + }; let timeout = Duration::from_secs(args.timeout); let idle_timeout = Duration::from_secs(args.idle_timeout); @@ -751,8 +769,12 @@ pub(crate) fn execute_work(args: WorkArgs, log: &mut dyn std::io::Write) -> Work /// Runs an in-process work-item dispatch job for `agentflare_jobs::WorkerPool` /// (see `dispatch_item` in `src/supervisor.rs`, which enqueues jobs this /// executes) instead of the daemon spawning a fresh `agentflare work` -/// subprocess per item. `args` is `[item_id, agent]` — see `dispatch_item` -/// for how it's built. +/// subprocess per item. `args` is `[item_id, agent, folder_path]` — see +/// `dispatch_item` for how it's built. `folder_path` is optional on read +/// (via `args.get(2)`, not destructured like the first two) so a job +/// already queued from before item #63 — `[item_id, agent]` only — still +/// runs (against this process's cwd, the pre-#63 behavior) instead of +/// failing outright on daemon upgrade. pub struct WorkItemExecutor; impl agentflare_jobs::InProcessExecutor for WorkItemExecutor { @@ -768,6 +790,7 @@ impl agentflare_jobs::InProcessExecutor for WorkItemExecutor { ) .into()); }; + let repo_root = args.get(2).map(std::path::PathBuf::from); let work_args = WorkArgs { target: item_id.clone(), agent: Some(agent.clone()), @@ -776,6 +799,7 @@ impl agentflare_jobs::InProcessExecutor for WorkItemExecutor { max_turns: None, max_cost_usd: None, notify: None, + repo_root, }; // `:` — the job's own queue id is a natural instance // discriminator, playing the role a subprocess's unique pid plays diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 8dd5da32..455a256d 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -625,6 +625,25 @@ impl AgentflareMcp { } } + /// Scopes this instance to a specific project's repo root instead of the + /// process's own cwd — used when dispatching or running work against a + /// project other than whichever repo this process happens to have been + /// started from. `backend_db`/`store` stay defaulted (those are the + /// single shared system-wide stores, not per-repo); only project-link + /// and worktree resolution — the two cwd-derived axes — are pinned to + /// `repo_root`. See `supervisor::dispatch_item` (which reads the + /// target's folder from the `project_dirs` registry) and + /// `cli::work::WorkItemExecutor`, which thread it through here. + pub(crate) fn for_project_dir(repo_root: std::path::PathBuf) -> Self { + Self { + backend_project_link_override: Some( + repo_root.join(Self::LINK_MARKER).join("project.json"), + ), + worktree_repo_root_override: Some(repo_root), + ..Default::default() + } + } + /// Pure walk-up so the non-git fallback path is unit-testable without /// touching process-global state: neither this process's real cwd nor /// `crate::paths::home()` (which itself reads the `AGENTFLARE_HOME_OVERRIDE` @@ -874,6 +893,7 @@ impl AgentflareMcp { match agentflare_backend::project::get(conn, &link.project_id) { Ok(project) => { self.register_bridge_repo(conn, &project.id); + self.register_project_dir(conn, &project.id); return Ok(project); } Err(agentflare_backend::Error::NotFound(_)) => {} // stale link — re-resolve below @@ -935,6 +955,7 @@ impl AgentflareMcp { serde_json::to_vec_pretty(&link).unwrap_or_default(), ); self.register_bridge_repo(conn, &project.id); + self.register_project_dir(conn, &project.id); Ok(project) } @@ -964,6 +985,25 @@ impl AgentflareMcp { ); } + /// Refreshes this repo's row in the general project-directory registry + /// (`project_dirs`) — the reverse of `project.json`'s folder→project + /// link, indexed by project instead so a process with no reliable cwd of + /// its own (the daemon's supervisor discovery loop) can enumerate every + /// project's folder it should operate against. Unlike + /// `register_bridge_repo`, not gated on a GitHub remote: every linked + /// project gets a row here. Best-effort — a registry write failure must + /// not break project resolution, which every MCP/CLI call depends on. + fn register_project_dir(&self, conn: &rusqlite::Connection, project_id: &str) { + let repo_root = Self::repo_root(); + let folder_path = std::fs::canonicalize(&repo_root).unwrap_or(repo_root); + let _ = agentflare_backend::project_dir::upsert( + conn, + project_id, + &folder_path.to_string_lossy(), + crate::claims::now(), + ); + } + /// NotFound and InvalidInput (version conflict) are caller-fixable → /// invalid_params; everything else is an internal error. fn artifact_error(e: std::io::Error) -> ErrorData { diff --git a/src/supervisor.rs b/src/supervisor.rs index f09e31fe..375fb245 100644 --- a/src/supervisor.rs +++ b/src/supervisor.rs @@ -57,10 +57,24 @@ pub(crate) struct DiscoveryTickResult { pub waiting: usize, } -/// One pass: list items labeled `ready-for-work`, dispatch a job for each -/// one with a confirmed-autonomous assignee, skip (+ comment + relabel) the -/// rest. Ends after enqueueing — it does not watch job completion, since -/// `agentflare work` itself reports outcome back onto the item. +/// Everything one project contributes to a discovery tick: its own +/// `ready-for-work` items plus the folder its worktrees must be created +/// under (from the `project_dirs` registry, not this process's cwd). +struct ProjectBatch { + folder_path: String, + items: Vec, + label_id_by_name: std::collections::HashMap, + ready_id: String, +} + +/// One pass: across every project registered in `project_dirs` (see +/// `AgentflareMcp::register_project_dir`, called wherever an agentflare +/// CLI/MCP call runs inside a linked repo) — not just whichever project +/// this daemon process happens to have been started from (item #63) — +/// list items labeled `ready-for-work`, dispatch a job for each one with a +/// confirmed-autonomous assignee, skip (+ comment + relabel) the rest. Ends +/// after enqueueing — it does not watch job completion, since `agentflare +/// work` itself reports outcome back onto the item. pub(crate) fn run_discovery_tick( mcp: &AgentflareMcp, queue: &agentflare_jobs::Queue, @@ -73,78 +87,105 @@ pub(crate) fn run_discovery_tick( }; let fetched = mcp.with_backend_db(|conn| { - let project = mcp.resolve_project(conn).ok()?; - let labels = agentflare_backend::label::list_by_project(conn, &project.id).ok()?; - let mut label_id_by_name = std::collections::HashMap::new(); - for l in &labels { - label_id_by_name.insert(l.name.clone(), l.id.clone()); + let dirs = agentflare_backend::project_dir::list(conn).ok()?; + let mut batches = Vec::new(); + for dir in dirs { + let labels = agentflare_backend::label::list_by_project(conn, &dir.project_id).ok()?; + let mut label_id_by_name = std::collections::HashMap::new(); + for l in &labels { + label_id_by_name.insert(l.name.clone(), l.id.clone()); + } + // A project without the ready-for-work label (yet) has nothing + // to discover — skip just this one, not the whole tick. + let Some(ready_id) = label_id_by_name.get(READY_LABEL).cloned() else { + continue; + }; + let items = + agentflare_backend::item::list_by_label(conn, &dir.project_id, &ready_id).ok()?; + batches.push(ProjectBatch { + folder_path: dir.folder_path, + items, + label_id_by_name, + ready_id, + }); } - let ready_id = label_id_by_name.get(READY_LABEL)?.clone(); - let items = agentflare_backend::item::list_by_label(conn, &project.id, &ready_id).ok()?; - Some((items, label_id_by_name)) + Some(batches) }); - let Ok(Some((items, label_id_by_name))) = fetched else { - return result; - }; - let Some(ready_id) = label_id_by_name.get(READY_LABEL).cloned() else { + let Ok(Some(batches)) = fetched else { return result; }; - for item in items { - match crate::quota::decide::decide_for_supervisor(mcp, &item) { - crate::quota::decide::EffectiveAction::Run - | crate::quota::decide::EffectiveAction::SelfRepair => { - let Some(agent) = item - .assignee_agent - .as_deref() - .and_then(resolve_confirmed_agent) - else { - // decide() already checked eligibility (tier 5) before - // returning Run/SelfRepair, so this is unreachable in - // practice; treat it the same as the pre-existing skip - // path rather than panicking on a decision-vs-dispatch - // mismatch. - skip_item(mcp, &item, &label_id_by_name, &ready_id); + for batch in batches { + let ProjectBatch { + folder_path, + items, + label_id_by_name, + ready_id, + } = batch; + for item in items { + match crate::quota::decide::decide_for_supervisor(mcp, &item) { + crate::quota::decide::EffectiveAction::Run + | crate::quota::decide::EffectiveAction::SelfRepair => { + let Some(agent) = item + .assignee_agent + .as_deref() + .and_then(resolve_confirmed_agent) + else { + // decide() already checked eligibility (tier 5) before + // returning Run/SelfRepair, so this is unreachable in + // practice; treat it the same as the pre-existing skip + // path rather than panicking on a decision-vs-dispatch + // mismatch. + skip_item(mcp, &item, &label_id_by_name, &ready_id); + result.skipped += 1; + continue; + }; + if crate::auth_db::is_cooling_down(auth_conn, agent.as_str()) { + // Leave the ready-for-work label in place, same as the + // Wait branch below: the cooldown may clear before the + // next tick, and the item must still be visible to that + // tick's discovery query. + eprintln!( + "agentflare-supervisor: item #{} ({}) is ready-for-work but agent '{}' is cooling down", + item.sequence_id, + item.id, + agent.as_str() + ); + result.waiting += 1; + continue; + } + if dispatch_item( + mcp, + queue, + &item, + agent, + &folder_path, + &label_id_by_name, + &ready_id, + ) { + result.dispatched += 1; + } + } + crate::quota::decide::EffectiveAction::Ask(question) => { + ask_item(mcp, &item, &question, &label_id_by_name, &ready_id); result.skipped += 1; - continue; - }; - if crate::auth_db::is_cooling_down(auth_conn, agent.as_str()) { - // Leave the ready-for-work label in place, same as the - // Wait branch below: the cooldown may clear before the - // next tick, and the item must still be visible to that - // tick's discovery query. + } + crate::quota::decide::EffectiveAction::Wait(reason) => { + // Leave the ready-for-work label in place: the wait + // condition may clear before the next tick, and the item + // must still be visible to that tick's discovery query. eprintln!( - "agentflare-supervisor: item #{} ({}) is ready-for-work but agent '{}' is cooling down", - item.sequence_id, - item.id, - agent.as_str() + "agentflare-supervisor: item #{} ({}) is ready-for-work but waiting: {reason}", + item.sequence_id, item.id ); result.waiting += 1; - continue; } - if dispatch_item(mcp, queue, &item, agent, &label_id_by_name, &ready_id) { - result.dispatched += 1; + crate::quota::decide::EffectiveAction::StayQuiet => { + skip_item(mcp, &item, &label_id_by_name, &ready_id); + result.skipped += 1; } } - crate::quota::decide::EffectiveAction::Ask(question) => { - ask_item(mcp, &item, &question, &label_id_by_name, &ready_id); - result.skipped += 1; - } - crate::quota::decide::EffectiveAction::Wait(reason) => { - // Leave the ready-for-work label in place: the wait - // condition may clear before the next tick, and the item - // must still be visible to that tick's discovery query. - eprintln!( - "agentflare-supervisor: item #{} ({}) is ready-for-work but waiting: {reason}", - item.sequence_id, item.id - ); - result.waiting += 1; - } - crate::quota::decide::EffectiveAction::StayQuiet => { - skip_item(mcp, &item, &label_id_by_name, &ready_id); - result.skipped += 1; - } } } result @@ -219,20 +260,29 @@ fn ask_item( /// only (shown in the dashboard's job list); nothing spawns it, so master's /// `current_exe()`-staleness fix (see git history) is moot here: there's no /// exe path to resolve at all once dispatch never spawns one. `args` is -/// `[item_id, agent]`, exactly what `WorkItemExecutor::execute` expects. +/// `[item_id, agent]`, plus `folder_path` when the caller has one (item +/// #63) — `WorkItemExecutor::execute` claims/worktrees against that folder +/// instead of wherever this daemon process happens to have started. /// -/// Shared by `dispatch_item` (a fresh `ready-for-work` item) and -/// `run_review_sweep`'s self-repair path (item #65, re-running the same job -/// on an item already sitting in "in_review" -- `item_claim` reclaims its -/// existing worktree/branch rather than starting over, see `item::claim`'s -/// doc comment). +/// Shared by `dispatch_item` (a fresh `ready-for-work` item, always passes +/// its per-project `folder_path`) and `run_review_sweep`'s self-repair path +/// (item #65, re-running the same job on an item already sitting in +/// "in_review" -- `item_claim` reclaims its existing worktree/branch rather +/// than starting over, see `item::claim`'s doc comment). `run_review_sweep` +/// itself is still single-project (`resolve_project`'s cwd-based +/// resolution), so it has no folder path of its own to pass yet. fn enqueue_work_job( queue: &agentflare_jobs::Queue, item: &agentflare_backend::item::Item, agent: agent_registry::Agent, + folder_path: Option<&str>, ) -> Option { + let mut args = vec![item.id.clone(), agent.as_str().to_string()]; + if let Some(folder_path) = folder_path { + args.push(folder_path.to_string()); + } let job = agentflare_jobs::AgentJob::new("agentflare-work") - .args([item.id.clone(), agent.as_str().to_string()]) + .args(args) .timeout(WORK_JOB_TIMEOUT_SECS) .in_process(); queue.enqueue(&job).ok() @@ -243,10 +293,11 @@ fn dispatch_item( queue: &agentflare_jobs::Queue, item: &agentflare_backend::item::Item, agent: agent_registry::Agent, + folder_path: &str, label_id_by_name: &std::collections::HashMap, ready_id: &str, ) -> bool { - let Some(info) = enqueue_work_job(queue, item, agent) else { + let Some(info) = enqueue_work_job(queue, item, agent, Some(folder_path)) else { return false; }; @@ -463,7 +514,7 @@ fn self_repair_or_gate( if crate::auth_db::is_cooling_down(auth_conn, agent.as_str()) { return false; } - let Some(info) = enqueue_work_job(queue, item, agent) else { + let Some(info) = enqueue_work_job(queue, item, agent, None) else { return false; }; let _ = mcp.comment_impl(CommentRequest { @@ -936,6 +987,86 @@ mod tests { assert!(labels_contain_name(&mcp, &labels, "needs-manual-dispatch")); } + /// Seeds a ready-for-work item in a brand new project/workspace + /// (independent of whatever `test_mcp()`'s cwd-resolved project is) and + /// registers it in `project_dirs` at `folder_path` — the same registry + /// `AgentflareMcp::register_project_dir` populates for a real repo, but + /// written directly here so the test controls the folder path without + /// needing a real linked repo on disk. + fn seed_ready_item_in_project(mcp: &AgentflareMcp, name: &str, folder_path: &str) -> String { + mcp.with_backend_db(|conn| { + let workspace = agentflare_backend::workspace::create( + conn, + agentflare_backend::workspace::CreateWorkspace { + name: name.into(), + slug: name.into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let project = agentflare_backend::project::create( + conn, + agentflare_backend::project::CreateProject { + workspace_id: workspace.id, + name: name.into(), + identifier: name.into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + agentflare_backend::project_dir::upsert(conn, &project.id, folder_path, 1).unwrap(); + for label_name in ["ready-for-work", "dispatched", "needs-manual-dispatch"] { + agentflare_backend::label::create( + conn, + agentflare_backend::label::CreateLabel { + project_id: Some(project.id.clone()), + workspace_id: project.workspace_id.clone(), + name: label_name.into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + } + let states = agentflare_backend::state::list_by_project(conn, &project.id).unwrap(); + let state_id = states.iter().find(|s| s.is_default).unwrap().id.clone(); + let item = agentflare_backend::item::create( + conn, + agentflare_backend::item::CreateItem { + project_id: project.id.clone(), + state_id, + name: "Do the thing".into(), + description: Some("do it well".into()), + priority: None, + parent_id: None, + assignee_agent: Some("claude-code".into()), + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let labels = agentflare_backend::label::list_by_project(conn, &project.id).unwrap(); + let ready_id = &labels + .iter() + .find(|l| l.name == "ready-for-work") + .unwrap() + .id; + agentflare_backend::item::add_label(conn, &item.id, ready_id).unwrap(); + item.id + }) + .unwrap() + } + // --- run_review_sweep / self_repair_or_gate (item #65) --- /// A throwaway git repo with no remote -- same trick @@ -1015,6 +1146,41 @@ mod tests { .unwrap() } + #[test] + fn run_discovery_tick_dispatches_ready_items_from_every_registered_project_not_just_one() { + // Item #63: the daemon's own cwd-resolved project must not be the + // only project discovery ever looks at — every project registered + // in `project_dirs` (populated by any CLI/MCP call that ever ran + // inside it) must get its ready-for-work items picked up too. + let mcp = test_mcp(); + let queue = test_queue(); + let item_a = seed_ready_item_in_project(&mcp, "proj-a", "/repo/a"); + let item_b = seed_ready_item_in_project(&mcp, "proj-b", "/repo/b"); + + let auth_conn = test_auth_conn(); + let result = run_discovery_tick(&mcp, &queue, &auth_conn); + + assert_eq!( + result.dispatched, 2, + "both projects' ready items must be dispatched, not just one" + ); + let jobs = queue.list(None).unwrap(); + assert_eq!(jobs.len(), 2); + + let job_a = jobs.iter().find(|j| j.args.contains(&item_a)).unwrap(); + assert!( + job_a.args.contains(&"/repo/a".to_string()), + "job for proj-a's item must carry proj-a's own folder path, got {:?}", + job_a.args + ); + let job_b = jobs.iter().find(|j| j.args.contains(&item_b)).unwrap(); + assert!( + job_b.args.contains(&"/repo/b".to_string()), + "job for proj-b's item must carry proj-b's own folder path, got {:?}", + job_b.args + ); + } + fn seed_gate_label(mcp: &AgentflareMcp) -> std::collections::HashMap { mcp.with_backend_db(|conn| { let project = mcp.resolve_project(conn).unwrap();