diff --git a/crates/agentflare-backend/src/item/claim.rs b/crates/agentflare-backend/src/item/claim.rs index 8e24c349..f075b5f8 100644 --- a/crates/agentflare-backend/src/item/claim.rs +++ b/crates/agentflare-backend/src/item/claim.rs @@ -36,6 +36,15 @@ pub fn agent_part(owner: &str) -> String { agent_registry::canonicalize(owner.split(':').next().unwrap_or(owner)) } +/// Strips a `model` key out of an item's `metadata` JSON, returning the +/// re-serialized object only when a `model` key was actually present (so +/// callers can skip a pointless update when there's nothing to clear). +fn without_model_metadata(metadata: &str) -> Option { + let mut value: serde_json::Value = serde_json::from_str(metadata).ok()?; + let removed = value.as_object_mut()?.remove("model").is_some(); + removed.then(|| value.to_string()) +} + /// Claims an item so other agents don't duplicate the work: on a fresh /// acquire, sets the assignee and moves state into the project's "started" /// group (which sets `started_at`, via `update_state`). A live claim held by @@ -294,6 +303,21 @@ pub fn redispatch( None => return Ok(RedispatchOutcome::NoAssignee), }; + // `metadata.model` (see `item_model_override` in `supervisor.rs`) is + // scoped to whichever agent it was set alongside — forwarding it to a + // *different* agent's `--model` flag 404s (reproduced live on item + // #132: an opencode model string fed to `claude --model`). The previous + // assignee is the best proxy we have for "which agent the model was + // scoped to" since we don't store that separately, so clear it whenever + // redispatch is about to hand the item to someone else. + let agent_changed = + item.assignee_agent.as_deref().map(agent_part).as_deref() != Some(agent.as_str()); + let metadata = if agent_changed { + without_model_metadata(&item.metadata) + } else { + None + }; + let backlog_state = crate::state::first_in_group(&tx, &item.project_id, "backlog")?; update_state(&tx, item_id, &backlog_state.id)?; update( @@ -301,6 +325,7 @@ pub fn redispatch( item_id, UpdateItem { assignee_agent: Some(agent.clone()), + metadata, ..Default::default() }, )?; diff --git a/crates/agentflare-backend/src/item/tests.rs b/crates/agentflare-backend/src/item/tests.rs index 86ff4ed9..104bcdd7 100644 --- a/crates/agentflare-backend/src/item/tests.rs +++ b/crates/agentflare-backend/src/item/tests.rs @@ -1604,6 +1604,65 @@ fn redispatch_explicit_assignee_overrides_the_items_existing_one() { ); } +#[test] +fn redispatch_to_a_different_agent_clears_a_model_scoped_to_the_old_one() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "opencode:1", 1000, TTL).unwrap(); + update( + &conn, + &item.id, + UpdateItem { + metadata: Some(r#"{"model":"opencode-go/deepseek-v4-pro","size":"M"}"#.to_string()), + ..Default::default() + }, + ) + .unwrap(); + + let outcome = redispatch(&conn, &item.id, Some("claude-code:override")).unwrap(); + assert_eq!( + outcome, + RedispatchOutcome::Ready { + assignee_agent: "claude-code".to_string() + } + ); + + let updated = get(&conn, &item.id).unwrap(); + let metadata: serde_json::Value = serde_json::from_str(&updated.metadata).unwrap(); + assert!( + metadata.get("model").is_none(), + "model scoped to opencode must not survive a redispatch to claude-code: {metadata:?}" + ); + assert_eq!(metadata.get("size").and_then(|v| v.as_str()), Some("M")); +} + +#[test] +fn redispatch_to_the_same_agent_keeps_the_model_override() { + let conn = db::open_in_memory().unwrap(); + let (pid, sid) = seed_project(&conn, ""); + let item = make_item(&conn, &pid, &sid); + claim(&conn, &item.id, "claude-code:1", 1000, TTL).unwrap(); + update( + &conn, + &item.id, + UpdateItem { + metadata: Some(r#"{"model":"claude-opus-4"}"#.to_string()), + ..Default::default() + }, + ) + .unwrap(); + + redispatch(&conn, &item.id, None).unwrap(); + + let updated = get(&conn, &item.id).unwrap(); + let metadata: serde_json::Value = serde_json::from_str(&updated.metadata).unwrap(); + assert_eq!( + metadata.get("model").and_then(|v| v.as_str()), + Some("claude-opus-4") + ); +} + #[test] fn redispatch_with_no_assignee_anywhere_returns_no_assignee_and_makes_no_changes() { let conn = db::open_in_memory().unwrap();