Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/agentflare-backend/src/item/claim.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -294,13 +303,29 @@ 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(
&tx,
item_id,
UpdateItem {
assignee_agent: Some(agent.clone()),
metadata,
..Default::default()
},
)?;
Expand Down
59 changes: 59 additions & 0 deletions crates/agentflare-backend/src/item/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading